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

feat(performance): 请求详情察看结果树 + 报告导出 Word/PDF/JSON + 采集标志修复

- 新增 PerformanceRequestDetail 表与任务/执行级采集开关(off/on/errors_only),执行器按模式采集请求报文,超限自动截断
- 新增请求明细两段式查询 API(列表 + 详情),规避 MySQL sort buffer 爆内存
- 报告页新增「请求详情」Tab:筛选(状态码簇/成败/关键字/耗时区间)+ 分页表格 + 70% 详情抽屉(完整请求/响应报文、复制按钮)
- 项目报告页任务明细新增「查看请求详情」入口(深度链接 activeTab=details)
- 「导出 JSON」升级为「导出 Word / PDF / JSON」下拉(exportReport.ts,ECharts base64 嵌入)
- 修复 run_task_async() 创建执行记录缺失 request_detail_enabled 导致采集标志恒为 off 的 bug
- 新增端到端自测脚本 probe_e2e_rerun.py(5.60 实测:200 条明细 + MySQL 快照 + 报告汇总全部通过)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 a3148868
......@@ -244,9 +244,18 @@ async def _ensure_columns(conn) -> None:
("performance_tasks", "csv_content", "TEXT DEFAULT ''"),
("performance_tasks", "csv_variable_mapping", "JSON"),
("performance_tasks", "api_summary", "JSON"),
# 性能测试:请求详情采集开关(旧库升级,2026-08-25 新增)
("performance_tasks", "request_detail_enabled", "VARCHAR(20) DEFAULT 'off'"),
# 性能测试:执行记录请求详情采集开关快照(旧库升级,2026-08-25 新增)
("performance_executions", "request_detail_enabled", "VARCHAR(20) DEFAULT 'off'"),
# 性能测试:执行记录请求详情截断标志(旧库升级,2026-08-25 新增)
("performance_executions", "request_detail_truncated", "BOOLEAN DEFAULT 0"),
# 性能测试:快照增强指标(快照级 p95 等已于上条添加,本行仅作记录)
# 性能测试:快照关联执行记录 ID(旧库升级,执行跳转闭环用)
("performance_snapshots", "execution_id", "VARCHAR(64) DEFAULT NULL"),
# 性能测试:快照资源监控数据(回放模式展示本机/目标机资源,2026-08-25 新增)
("performance_snapshots", "resource", "JSON"),
("performance_snapshots", "target_resource", "JSON"),
]
def _do_ensure(sync_conn) -> None:
......@@ -263,6 +272,20 @@ async def _ensure_columns(conn) -> None:
except Exception as e:
logger.warning(f"补齐字段失败 {table}.{column}: {e}")
# 创建新表(`performance_request_details` 等新表,SQLite 下 create_all 已处理,
# 但 MySQL 下 metadata.create_all 已处理所有表,这里仅做兜底检查)
# 注意:Base.metadata.create_all 在 init_db 中已调用,本处仅用于 SQLite
# 旧库升级场景——如果 create_all 已创建则跳过
from sqlalchemy import MetaData, Table
from app.models.performance import PerformanceRequestDetail
existing_tables = inspector.get_table_names()
if PerformanceRequestDetail.__tablename__ not in existing_tables:
try:
PerformanceRequestDetail.__table__.create(sync_conn)
logger.info(f"创建新表: {PerformanceRequestDetail.__tablename__}")
except Exception as e:
logger.warning(f"创建新表失败 {PerformanceRequestDetail.__tablename__}: {e}")
# AsyncConnection 不支持直接 inspect,必须通过 run_sync 在同步上下文中执行
await conn.run_sync(_do_ensure)
......
......@@ -244,6 +244,12 @@ class PerformanceTask(Base):
# 唯一性字段配置(每个请求自动生成唯一值,避免并发压测重复冲突)
unique_fields: Mapped[list] = mapped_column(JSON, default=list, comment="唯一性字段配置列表")
# 请求详情采集(JMeter 察看结果树,2026-08-25 新增)
# off=关闭(默认) / on=全部请求 / errors_only=仅失败请求
request_detail_enabled: Mapped[str] = mapped_column(
String(20), default="off", comment="请求详情采集: off/on/errors_only"
)
# 事务步骤列表(多步骤事务模式,None/空 = 单接口模式,向后兼容)
steps: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="事务步骤列表")
# 事务失败策略: fail_fast(失败即终止)/continue(继续执行后续步骤)
......@@ -378,6 +384,7 @@ class PerformanceTask(Base):
"assertions": self.assertions or [],
"capture_rules": self.capture_rules or [],
"unique_fields": self.unique_fields or [],
"request_detail_enabled": self.request_detail_enabled,
"steps": self.steps,
"transaction_fail_policy": self.transaction_fail_policy,
"transaction_timeout": self.transaction_timeout,
......@@ -483,6 +490,10 @@ class PerformanceSnapshot(Base):
status_4xx: Mapped[int] = mapped_column(Integer, default=0, comment="4xx计数")
status_5xx: Mapped[int] = mapped_column(Integer, default=0, comment="5xx计数")
# 资源监控数据(JSON 格式,用于回放模式展示)
resource: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="本机资源监控(CPU/内存/负载)")
target_resource: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="目标机资源监控(系统+MySQL)")
# 关联任务
task: Mapped["PerformanceTask"] = relationship("PerformanceTask", back_populates="snapshots")
......@@ -534,6 +545,8 @@ class PerformanceSnapshot(Base):
"status_3xx": self.status_3xx,
"status_4xx": self.status_4xx,
"status_5xx": self.status_5xx,
"resource": self.resource,
"target_resource": self.target_resource,
}
......@@ -651,6 +664,14 @@ class PerformanceExecution(Base):
batch_id: Mapped[Optional[str]] = mapped_column(
String(64), nullable=True, default=None, comment="关联批次ID"
)
# 请求详情采集开关快照(2026-08-25 新增,off/on/errors_only)
request_detail_enabled: Mapped[str] = mapped_column(
String(20), default="off", comment="请求详情采集开关快照"
)
# 请求详情是否因采样上限被截断(2026-08-25 新增)
request_detail_truncated: Mapped[bool] = mapped_column(
Boolean, default=False, comment="请求详情是否因采样上限被截断"
)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, comment="创建时间"
)
......@@ -716,6 +737,8 @@ class PerformanceExecution(Base):
"snapshot_count": self.snapshot_count,
"triggered_by": self.triggered_by,
"batch_id": self.batch_id,
"request_detail_enabled": self.request_detail_enabled,
"request_detail_truncated": self.request_detail_truncated,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
......@@ -892,4 +915,143 @@ class PerformanceProjectReport(Base):
"duration_actual": self.duration_actual,
"task_summaries": self.task_summaries,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class PerformanceRequestDetail(Base):
"""
性能测试请求明细模型
记录单条 HTTP 请求的完整请求报文与响应报文,供 JMeter 风格「察看结果树」展示。
默认不采集(request_detail_enabled=off),开关在任务配置中。
Attributes:
id (int): 自增主键
execution_id (str): 所属执行记录ID
task_id (str): 所属任务ID
api_name (str): 接口名(长稳多接口场景)
request_index (int): 请求序号
thread_idx (int): 虚拟用户索引
method (str): 请求方法
url (str): 请求 URL(动态变量解析后最终值)
request_headers (dict): 脱敏请求头
request_body (dict): 解析后请求体
status (int): HTTP 状态码(0=网络异常/超时)
response_headers (dict): 脱敏响应头
response_body (str): 截断响应体(默认前 8KB)
response_time_ms (float): 总耗时
latency_ms (float): 首字节延迟
connect_ms (float): 连接时间
sent_bytes (int): 发送字节
received_bytes (int): 接收字节
assert_result (bool): 断言是否通过
error_type (str): 错误类型
error_message (str): 错误消息(截断 500 字符)
success (bool): 是否成功
ts (datetime): 请求发生时间
"""
__tablename__ = "performance_request_details"
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True, comment="明细ID"
)
execution_id: Mapped[str] = mapped_column(
String(64), index=True, comment="所属执行记录ID"
)
task_id: Mapped[str] = mapped_column(
String(64), index=True, comment="所属任务ID"
)
api_name: Mapped[str] = mapped_column(
String(200), default="", comment="接口名"
)
request_index: Mapped[int] = mapped_column(
Integer, default=1, comment="请求序号"
)
thread_idx: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=None, comment="虚拟用户索引"
)
method: Mapped[str] = mapped_column(
String(10), default="GET", comment="请求方法"
)
url: Mapped[str] = mapped_column(Text, default="", comment="请求URL")
request_headers: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True, default=None, comment="脱敏请求头"
)
request_body: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True, default=None, comment="解析后请求体"
)
status: Mapped[int] = mapped_column(
Integer, default=0, comment="HTTP状态码(0=网络异常)"
)
response_headers: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True, default=None, comment="脱敏响应头"
)
response_body: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, default=None, comment="截断响应体"
)
response_time_ms: Mapped[float] = mapped_column(
Float, default=0.0, comment="总耗时(ms)"
)
latency_ms: Mapped[float] = mapped_column(
Float, default=0.0, comment="首字节延迟(ms)"
)
connect_ms: Mapped[float] = mapped_column(
Float, default=0.0, comment="连接时间(ms)"
)
sent_bytes: Mapped[int] = mapped_column(
Integer, default=0, comment="发送字节数"
)
received_bytes: Mapped[int] = mapped_column(
Integer, default=0, comment="接收字节数"
)
assert_result: Mapped[Optional[bool]] = mapped_column(
Boolean, nullable=True, default=None, comment="断言是否通过"
)
error_type: Mapped[Optional[str]] = mapped_column(
String(30), nullable=True, default=None, comment="错误类型"
)
error_message: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, default=None, comment="错误消息(截断500字符)"
)
success: Mapped[bool] = mapped_column(
Boolean, default=True, comment="是否成功"
)
ts: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, index=True, comment="请求发生时间"
)
def __repr__(self) -> str:
return (
f"<PerformanceRequestDetail(id={self.id}, "
f"execution_id={self.execution_id}, "
f"status={self.status}, success={self.success})>"
)
def to_dict(self) -> dict:
"""转换为字典"""
return {
"id": self.id,
"execution_id": self.execution_id,
"task_id": self.task_id,
"api_name": self.api_name,
"request_index": self.request_index,
"thread_idx": self.thread_idx,
"method": self.method,
"url": self.url,
"request_headers": self.request_headers,
"request_body": self.request_body,
"status": self.status,
"response_headers": self.response_headers,
"response_body": self.response_body,
"response_time_ms": self.response_time_ms,
"latency_ms": self.latency_ms,
"connect_ms": self.connect_ms,
"sent_bytes": self.sent_bytes,
"received_bytes": self.received_bytes,
"assert_result": self.assert_result,
"error_type": self.error_type,
"error_message": self.error_message,
"success": self.success,
"ts": self.ts.isoformat() if self.ts else None,
}
\ No newline at end of file
......@@ -52,6 +52,9 @@ from app.schemas.performance import (
ProjectReportListResponse,
AiAnalysisResponse,
AiAnalysisCompareRequest,
RequestDetailItem,
RequestDetailFullResponse,
RequestDetailListResponse,
)
from app.services.performance_service import PerformanceService
from app.services.performance_ai_service import PerformanceAiService
......@@ -430,6 +433,72 @@ async def get_execution_snapshots(
)
# ==================== 请求明细(JMeter 察看结果树) ====================
@router.get(
"/executions/{execution_id}/requests",
response_model=RequestDetailListResponse,
)
async def list_request_details(
execution_id: str,
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
status: Optional[str] = Query(None, description="状态码簇: 200/4xx/5xx/0"),
success: Optional[bool] = Query(None, description="成功/失败"),
api_name: Optional[str] = Query(None, description="接口名"),
keyword: Optional[str] = Query(None, description="URL/响应体关键字"),
min_response_ms: Optional[float] = Query(None, ge=0, description="最小响应时间(ms)"),
max_response_ms: Optional[float] = Query(None, ge=0, description="最大响应时间(ms)"),
sort: str = Query("ts", pattern="^(ts|response_time_ms)$", description="排序字段"),
order: str = Query("asc", pattern="^(asc|desc)$", description="排序方向"),
service: PerformanceService = Depends(get_perf_service),
):
"""
获取请求明细列表(两段式查询)
先只查 id 轻量排序分页,再按 id 回查整行,
避免 response_body 等大列参与 ORDER BY 触发 MySQL Out of sort memory。
"""
items, total, enabled, truncated = await service.get_request_details(
execution_id,
page=page,
page_size=page_size,
status=status,
success=success,
api_name=api_name,
keyword=keyword,
min_response_ms=min_response_ms,
max_response_ms=max_response_ms,
sort=sort,
order=order,
)
return RequestDetailListResponse(
total=total,
truncated=truncated,
request_detail_enabled=enabled,
items=[RequestDetailItem(**item.to_dict()) for item in items],
)
@router.get(
"/executions/{execution_id}/requests/{detail_id}",
response_model=RequestDetailFullResponse,
)
async def get_request_detail(
execution_id: str,
detail_id: int,
service: PerformanceService = Depends(get_perf_service),
):
"""
获取单条请求明细完整详情(含请求/响应报文)
"""
detail = await service.get_request_detail(execution_id, detail_id)
if not detail:
raise HTTPException(status_code=404, detail="请求明细不存在")
return RequestDetailFullResponse(**detail.to_dict())
@router.delete("/executions/{execution_id}", status_code=204)
async def delete_execution(
execution_id: str,
......
......@@ -165,6 +165,11 @@ class PerformanceTaskCreate(BaseModel):
transaction_fail_policy: str = Field("fail_fast", pattern=r"^(fail_fast|continue)$", description="事务失败策略: fail_fast/continue")
transaction_timeout: int = Field(60, ge=1, le=120, description="单事务超时(秒)")
# 请求详情采集(JMeter 察看结果树,2026-08-25 新增)
request_detail_enabled: str = Field(
"off", pattern=r"^(off|on|errors_only)$", description="请求详情采集: off/on/errors_only"
)
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......@@ -219,6 +224,11 @@ class PerformanceTaskUpdate(BaseModel):
transaction_fail_policy: Optional[str] = Field(None, pattern=r"^(fail_fast|continue)$", description="事务失败策略")
transaction_timeout: Optional[int] = Field(None, ge=1, le=120, description="单事务超时(秒)")
# 请求详情采集(JMeter 察看结果树,2026-08-25 新增)
request_detail_enabled: Optional[str] = Field(
None, pattern=r"^(off|on|errors_only)$", description="请求详情采集: off/on/errors_only"
)
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......@@ -285,6 +295,9 @@ class PerformanceTaskResponse(BaseModel):
# 接口维度指标汇总
api_summary: Optional[List[Dict[str, Any]]] = None
# 请求详情采集(JMeter 察看结果树,2026-08-25 新增)
request_detail_enabled: str = "off"
# 结果统计
total_requests: int = 0
success_count: int = 0
......@@ -472,6 +485,8 @@ class PerformanceSnapshotResponse(BaseModel):
status_3xx: int = Field(0, alias="status3xx")
status_4xx: int = Field(0, alias="status4xx")
status_5xx: int = Field(0, alias="status5xx")
resource: Optional[Dict[str, Any]] = None
target_resource: Optional[Dict[str, Any]] = Field(None, alias="targetResource")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......@@ -631,6 +646,63 @@ class PerformanceSnapshotListResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 请求明细(JMeter 察看结果树) ====================
class RequestDetailItem(BaseModel):
"""请求明细列表项(轻量,不含大报文)"""
id: int
api_name: str = ""
request_index: int = 1
thread_idx: Optional[int] = None
method: str = "GET"
url: str = ""
status: int = 0
response_time_ms: float = 0.0
success: bool = True
assert_result: Optional[bool] = None
error_type: Optional[str] = None
ts: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, from_attributes=True)
class RequestDetailFullResponse(BaseModel):
"""请求明细完整详情(含请求/响应报文)"""
id: int
api_name: str = ""
request_index: int = 1
thread_idx: Optional[int] = None
method: str = "GET"
url: str = ""
request_headers: Optional[Dict[str, Any]] = None
request_body: Optional[Any] = None
status: int = 0
response_headers: Optional[Dict[str, Any]] = None
response_body: Optional[str] = None
response_time_ms: float = 0.0
latency_ms: float = 0.0
connect_ms: float = 0.0
sent_bytes: int = 0
received_bytes: int = 0
assert_result: Optional[bool] = None
error_type: Optional[str] = None
error_message: Optional[str] = None
success: bool = True
ts: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, from_attributes=True)
class RequestDetailListResponse(BaseModel):
"""请求明细列表响应"""
total: int
truncated: bool = False
request_detail_enabled: str = "off"
items: List[RequestDetailItem] = []
model_config = ConfigDict(populate_by_name=True, from_attributes=True)
# ==================== 性能测试项目 ====================
class PerformanceProjectCreate(BaseModel):
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
端到端复测脚本(部署修复后):
1. 触发任务执行(POST /api/performance/tasks/{task_id}/run)
2. 轮询执行记录直至运行结束
3. 校验:
- 新执行记录 request_detail_enabled 快照 = 任务值(预期 'on')
- 请求明细 total(预期 ~200,truncated=False)
- 快照 resource / targetResource(mysql 指标)
- 报告 API targetResourceSummary.mysql
4. 打印结果供人工/文档核对
"""
import asyncio
import json
import sys
import time
import aiohttp
BASE = "http://127.0.0.1:80"
TASK_ID = sys.argv[1] if len(sys.argv) > 1 else "perf_4fb439c759a94f54b3c64829714564fa"
async def main() -> None:
async with aiohttp.ClientSession() as session:
# 1. 触发执行
async with session.post(f"{BASE}/api/performance/tasks/{TASK_ID}/run") as resp:
run = await resp.json()
print("RUN RESP:", resp.status, json.dumps(run, ensure_ascii=False))
execution_id = run.get("execution_id") or run.get("executionId")
if not execution_id:
print("NO execution_id, abort")
return
# 2. 轮询执行记录(API 返回 camelCase;request_detail_enabled/truncated 由请求明细 API 提供)
status = ""
for i in range(120):
await asyncio.sleep(2)
async with session.get(f"{BASE}/api/performance/executions/{execution_id}") as resp:
if resp.status != 200:
continue
ex = await resp.json()
status = ex.get("status", "")
if status in ("completed", "failed", "cancelled"):
break
print(f"EXECUTION STATUS: {status} (after { (i+1)*2 }s)")
async with session.get(f"{BASE}/api/performance/executions/{execution_id}") as resp:
ex = await resp.json()
print("EXECUTION KEYS:", sorted(ex.keys()))
print("totalRequests:", ex.get("totalRequests"), "successCount:", ex.get("successCount"))
print("actualTps:", ex.get("actualTps"))
trs = ex.get("targetResourceSummary")
print("targetResourceSummary present:", bool(trs))
if trs:
mysql = trs.get("mysql")
print(" -> mysql:", json.dumps(mysql, ensure_ascii=False))
# 3. 请求明细
async with session.get(
f"{BASE}/api/performance/executions/{execution_id}/requests",
params={"page": 1, "page_size": 3},
) as resp:
rd = await resp.json()
print("REQ DETAILS: total =", rd.get("total"),
"truncated =", rd.get("truncated"),
"request_detail_enabled =", rd.get("request_detail_enabled"),
"items =", len(rd.get("items") or []))
if rd.get("items"):
print(" item[0] keys:", sorted(rd["items"][0].keys()))
# 4. 快照 resource / targetResource
async with session.get(
f"{BASE}/api/performance/executions/{execution_id}/snapshots",
params={"page_size": 5000},
) as resp:
snaps = await resp.json()
items = snaps.get("items") or []
print(f"SNAPSHOTS: total={len(items)}")
if items:
last = items[-1]
print(" last snapshot keys:", sorted(last.keys()))
print(" resource present:", bool(last.get("resource")))
print(" targetResource present:", bool(last.get("targetResource")))
tr = last.get("targetResource") or {}
mysql = tr.get("mysql")
print(" targetResource.mysql:", json.dumps(mysql, ensure_ascii=False))
# 5. 报告 API
async with session.get(f"{BASE}/api/performance/executions/{execution_id}/report") as resp:
report = await resp.json()
print("REPORT KEYS:", sorted(report.keys()))
trs2 = report.get("targetResourceSummary")
print(" targetResourceSummary present:", bool(trs2))
if trs2:
print(" targetResourceSummary.mysql:", json.dumps(trs2.get("mysql"), ensure_ascii=False))
rs = report.get("resourceSummary")
print(" resourceSummary present:", bool(rs))
print(" snapshot[0] resource present:", bool((report.get("snapshots") or [{}])[0].get("resource")))
print(" snapshot[0] targetResource present:", bool((report.get("snapshots") or [{}])[0].get("targetResource")))
if __name__ == "__main__":
t0 = time.time()
asyncio.run(main())
print(f"TOTAL {time.time() - t0:.1f}s")
\ No newline at end of file
......@@ -29,6 +29,8 @@ import type {
ProjectReportResponse,
ProjectReportListResponse,
AiAnalysisResponse,
RequestDetailListResponse,
RequestDetailFull,
} from '@/types/performance'
const BASE = '/api/performance'
......@@ -361,4 +363,47 @@ export function getAiAnalysis(executionId: string): Promise<AiAnalysisResponse>
/** 多版本对比 AI 分析 */
export function getAiComparison(executionIds: string[]): Promise<AiAnalysisResponse> {
return request.post(`${BASE}/ai-analysis/compare`, { executionIds })
}
// ==================== 请求详情(察看结果树) ====================
/** 获取请求明细列表 */
export function listRequestDetails(
executionId: string,
params?: {
page?: number
pageSize?: number
status?: string
success?: boolean
api_name?: string
keyword?: string
min_response_ms?: number
max_response_ms?: number
sort?: string
order?: string
},
): Promise<RequestDetailListResponse> {
const queryParams: Record<string, any> = { ...params }
if (params?.pageSize !== undefined) {
queryParams.page_size = params.pageSize
delete queryParams.pageSize
}
if (params?.api_name !== undefined) {
queryParams.api_name = params.api_name
delete queryParams.api_name
}
if (params?.min_response_ms !== undefined) {
queryParams.min_response_ms = params.min_response_ms
delete queryParams.min_response_ms
}
if (params?.max_response_ms !== undefined) {
queryParams.max_response_ms = params.max_response_ms
delete queryParams.max_response_ms
}
return request.get(`${BASE}/executions/${executionId}/requests`, { params: queryParams })
}
/** 获取单条请求明细详情 */
export function getRequestDetail(executionId: string, detailId: number): Promise<RequestDetailFull> {
return request.get(`${BASE}/executions/${executionId}/requests/${detailId}`)
}
\ No newline at end of file
......@@ -238,6 +238,24 @@ export interface TargetResourceSummary {
javaProcessStats?: JavaProcessStats[] | null
/** Java 进程趋势序列(仅当匹配到进程时存在) */
javaProcessSeries?: JavaProcessSeries[] | null
/** MySQL 服务监控汇总(仅当 MySQL 容器可采集时存在) */
mysql?: {
available: boolean
container: string
version: string
threadsConnected: number
threadsRunning: number
slowQueries: number
bufferPoolHitRate: number
maxUsedConnections: number
maxConnections: number
uptime: number
connections: number
questions: number
queries: number
bytesReceivedMb: number
bytesSentMb: number
} | null
}
/** 事务步骤汇总 */
......@@ -361,6 +379,8 @@ export interface PerformanceTask {
csvVariableMapping?: Record<string, string> | null
/** 接口级统计汇总 */
apiSummary?: ApiSummaryData[] | null
/** 请求详情采集模式(off/on/errors_only) */
requestDetailEnabled?: string
/** 执行机资源监控汇总 */
resourceSummary?: ResourceSummary | null
/** 事务处理时间汇总 */
......@@ -413,6 +433,8 @@ export interface PerformanceTaskCreate {
csvParameterizationEnabled?: boolean
csvContent?: string
csvVariableMapping?: Record<string, string> | null
/** 请求详情采集模式(off/on/errors_only) */
requestDetailEnabled?: RequestDetailMode
}
/** 更新任务请求 */
......@@ -459,6 +481,8 @@ export interface PerformanceTaskUpdate {
csvParameterizationEnabled?: boolean
csvContent?: string
csvVariableMapping?: Record<string, string> | null
/** 请求详情采集模式(off/on/errors_only) */
requestDetailEnabled?: RequestDetailMode
}
/** 任务列表项 */
......@@ -849,6 +873,48 @@ export interface PerformanceProjectStats {
lastRunTime: string | null
}
// ==================== 请求详情(察看结果树) ====================
/** 请求详情采集模式 */
export type RequestDetailMode = 'off' | 'on' | 'errors_only'
/** 请求明细列表项 */
export interface RequestDetailItem {
id: number
api_name: string
request_index: number
method: string
url: string
status: number
response_time_ms: number
success: boolean
assert_result: boolean | null
error_type: string | null
ts: string
}
/** 请求明细列表响应 */
export interface RequestDetailListResponse {
total: number
truncated: boolean
request_detail_enabled: string
items: RequestDetailItem[]
}
/** 请求明细完整详情(抽屉展示) */
export interface RequestDetailFull extends RequestDetailItem {
thread_idx: number | null
request_headers: Record<string, string> | null
request_body: any
response_headers: Record<string, string> | null
response_body: string | null
latency_ms: number
connect_ms: number
sent_bytes: number
received_bytes: number
error_message: string | null
}
// ==================== 批量执行 ====================
/** 批量执行请求 */
......
/**
* 性能测试报告导出工具
*
* 提供 Word (.doc) 和 PDF 导出功能,通过前端 HTML 渲染 + 浏览器能力实现,
* 不依赖后端 reportlab 等服务端库。
*
* @author czj
* @date 2026-08-25
*/
import type { ECharts } from 'echarts'
/**
* 导出 Word 文档(HTML 转 .doc)
*
* 原理:构建带样式的 HTML 页面,以 application/msword MIME 类型下载,
* Word 可直接打开 HTML 格式的 .doc 文件。
*
* @param title 文档标题
* @param htmlContent 完整 HTML 内容(含样式、表格、图表 base64 图片等)
* @param filename 下载文件名(不含扩展名)
*/
export function exportWord(title: string, htmlContent: string, filename: string): void {
const fullHtml = buildExportHtml(title, htmlContent)
const blob = new Blob([fullHtml], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.doc`
a.click()
URL.revokeObjectURL(url)
}
/**
* 导出 PDF(通过浏览器打印)
*
* 原理:在新窗口渲染报告 HTML,调用 window.print(),
* 用户选择「另存为 PDF」完成导出。
*
* @param title 文档标题
* @param htmlContent 完整 HTML 内容(含样式、表格、图表 base64 图片等)
*/
export function exportPdf(title: string, htmlContent: string): void {
const fullHtml = buildExportHtml(title, htmlContent)
const win = window.open('', '_blank')
if (!win) {
console.error('无法打开新窗口导出 PDF,请检查浏览器弹窗设置')
return
}
win.document.write(fullHtml)
win.document.title = title
win.document.close()
// 等待图片加载完成后打印
setTimeout(() => {
win.focus()
win.print()
// 不关闭窗口,让用户自行关闭
}, 500)
}
/**
* 导出 JSON 数据
*
* @param data 要导出的数据对象
* @param filename 下载文件名(不含扩展名)
*/
export function exportJson(data: any, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.json`
a.click()
URL.revokeObjectURL(url)
}
/**
* 从 ECharts 实例获取 base64 图片
*
* @param chart ECharts 实例
* @param width 图片宽度(默认 800)
* @returns base64 图片 data URL,或空字符串(图表未初始化时)
*/
export function getChartImage(chart: ECharts | null): string {
if (!chart) return ''
try {
return chart.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: '#fff',
})
} catch {
return ''
}
}
/**
* 构建完整的导出 HTML 页面
*
* @param title 文档标题
* @param contentBody 内容主体 HTML
* @returns 完整 HTML 字符串
*/
function buildExportHtml(title: string, contentBody: string): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<style>
/* 打印样式 */
@media print {
@page { margin: 20mm 15mm; }
body { font-size: 11pt; }
.no-print { display: none !important; }
img { max-width: 100% !important; page-break-inside: avoid; }
table { page-break-inside: avoid; }
h1, h2, h3, h4 { page-break-after: avoid; }
}
/* 屏幕样式 */
body {
font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
color: #333;
padding: 20px;
max-width: 1100px;
margin: 0 auto;
line-height: 1.6;
}
h1 {
font-size: 22pt;
color: #303133;
border-bottom: 2px solid #409eff;
padding-bottom: 8px;
margin-bottom: 20px;
}
h2 {
font-size: 16pt;
color: #303133;
margin-top: 24px;
margin-bottom: 12px;
}
h3 {
font-size: 13pt;
color: #606266;
margin-top: 16px;
margin-bottom: 8px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
table, th, td {
border: 1px solid #dcdfe6;
}
th {
background: #f5f7fa;
font-weight: 600;
padding: 8px 10px;
text-align: left;
}
td {
padding: 6px 10px;
}
.metric-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 12px 0;
}
.metric-item {
flex: 1;
min-width: 140px;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px 16px;
text-align: center;
background: #fafafa;
}
.metric-value {
font-size: 18pt;
font-weight: 700;
color: #409eff;
}
.metric-label {
font-size: 9pt;
color: #909399;
margin-top: 4px;
}
.chart-image {
width: 100%;
max-width: 800px;
margin: 12px 0;
}
.section {
margin-bottom: 20px;
}
.summary-block {
margin: 12px 0;
}
.summary-block table {
width: 100%;
}
.summary-block td {
padding: 6px 12px;
}
.label-cell {
font-weight: 600;
color: #606266;
white-space: nowrap;
width: 140px;
}
.footer {
margin-top: 30px;
padding-top: 12px;
border-top: 1px solid #dcdfe6;
font-size: 9pt;
color: #909399;
text-align: center;
}
img {
max-width: 100%;
}
</style>
</head>
<body>
${contentBody}
<div class="footer">
<p>本报告由平台自动化测试系统生成 · ${new Date().toLocaleString('zh-CN', { hour12: false })}</p>
</div>
</body>
</html>`
}
/**
* HTML 转义
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
\ No newline at end of file
......@@ -632,7 +632,7 @@ async function loadReplayData() {
}
taskStatus.value = 'completed'
connectionStatus.value = 'disconnected'
connectionStatus.value = 'replay'
ElMessage.success(`已加载 ${snapshots.length} 条快照数据(回放模式)`)
// 等 DOM 更新后渲染图表
......@@ -710,9 +710,9 @@ function onTaskSelected(id: string) {
}
// 连接状态
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'replay'>('disconnected')
const connectionStatusText = computed(() => {
const map = { disconnected: '未连接', connecting: '连接中...', connected: '已连接' }
const map = { disconnected: '未连接', connecting: '连接中...', connected: '已连接', replay: '回放模式' }
return map[connectionStatus.value]
})
const taskStatus = ref<string>('')
......@@ -1217,7 +1217,7 @@ function handleSnapshot(data: any) {
/** 处理完成事件 */
function handleComplete(data: any) {
taskStatus.value = data.status || 'completed'
connectionStatus.value = 'disconnected'
connectionStatus.value = 'replay'
isRunning.value = false
ElMessage.success('性能测试已完成')
......@@ -1349,12 +1349,8 @@ watch(() => route.query.taskId, (newId) => {
}
nextTick(() => {
initCharts()
// 有 executionId 且非实时 → 回放模式,直接加载 API 数据
if (executionId.value && !isRunning.value) {
loadReplayData()
} else {
connectWs()
}
// 统一走 WebSocket 实时连接;任务已结束时会快速收到 complete 并回退到回放
connectWs()
})
}
})
......@@ -1369,29 +1365,20 @@ watch(() => route.query.executionId, (newExecId) => {
}
nextTick(() => {
initCharts()
if (isRunning.value) {
connectWs()
} else {
loadReplayData()
}
// 统一走 WebSocket 实时连接;任务已结束时会快速收到 complete 并回退到回放
connectWs()
})
}
})
onMounted(() => {
if (taskId.value) {
if (executionId.value && !isRunning.value) {
// 回放模式直接加载 API 数据
nextTick(() => {
initCharts()
loadReplayData()
})
} else {
nextTick(() => {
initCharts()
connectWs()
})
}
// 统一先尝试 WebSocket 实时连接
// 如果任务已结束,WebSocket 连接会快速返回,handleComplete 自动回退到 loadReplayData
nextTick(() => {
initCharts()
connectWs()
})
} else {
// 无任务时加载可选项 + 最近执行记录,方便从侧边栏直接进入
loadAvailableTasks()
......
......@@ -42,9 +42,19 @@
<el-button type="primary" :loading="loading" @click="loadReport">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button @click="exportReport">
<el-icon><Download /></el-icon>导出 JSON
</el-button>
<el-dropdown v-if="report" trigger="click" @command="onExportCommand">
<el-button>
<el-icon><Download /></el-icon>导出报告
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="word">导出 Word</el-dropdown-item>
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item>
<el-dropdown-item command="json" divided>导出 JSON</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
......@@ -227,11 +237,14 @@
<el-table-column label="TPS(吞吐量)" width="90">
<template #default="{ row }">{{ row.actualTps.toFixed(1) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="viewTaskReport(row.taskId)">
查看报告
</el-button>
<el-button link type="primary" size="small" @click="viewRequestDetails(row)">
查看请求详情
</el-button>
</template>
</el-table-column>
</el-table>
......@@ -253,9 +266,10 @@
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Refresh, Download } from '@element-plus/icons-vue'
import { ArrowLeft, Refresh, Download, ArrowDown } from '@element-plus/icons-vue'
import { getProjectReport, getBatchReport, getProject, getProjectReports } from '@/api/performance'
import type { ProjectReportResponse, PerformanceProject, ProjectReportListResponse } from '@/types/performance'
import { exportWord, exportPdf, exportJson } from '@/utils/exportReport'
const route = useRoute()
const router = useRouter()
......@@ -343,17 +357,122 @@ function viewTaskReport(taskId: string) {
router.push(`/performance/report?taskId=${taskId}`)
}
/** 导出 JSON */
function exportReport() {
/** 跳转到请求详情(察看结果树) */
function viewRequestDetails(row: any) {
router.push(`/performance/report?taskId=${row.taskId}&activeTab=details`)
}
/** 导出下拉菜单命令 */
function onExportCommand(command: string) {
if (!report.value) return
switch (command) {
case 'word':
exportWordToDoc()
break
case 'pdf':
exportPdfFromReport()
break
case 'json':
exportReportJson()
break
}
}
/** 导出报告 JSON */
function exportReportJson() {
if (!report.value) return
const blob = new Blob([JSON.stringify(report.value, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `report_${report.value.id || 'merged'}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('报告已导出')
exportJson(report.value, `report_${report.value.id || 'merged'}`)
}
/** 导出 Word */
function exportWordToDoc() {
if (!report.value) return
const r = report.value
const html = buildReportHtml(r)
exportWord(`合并报告 - ${r.id || 'merged'}`, html, `report_${r.id || 'merged'}`)
}
/** 导出 PDF */
function exportPdfFromReport() {
if (!report.value) return
const r = report.value
const html = buildReportHtml(r)
exportPdf(`合并报告 - ${r.id || 'merged'}`, html)
}
/** 构建合并报告 HTML 内容 */
function buildReportHtml(r: ProjectReportResponse): string {
const s = r.summary
return `
<h1>性能测试合并报告</h1>
<div class="section">
<h2>执行摘要</h2>
<table>
<tr><td class="label-cell">报告 ID</td><td>${r.id}</td></tr>
<tr><td class="label-cell">批次 ID</td><td>${r.batchId || '-'}</td></tr>
<tr><td class="label-cell">生成时间</td><td>${formatTime(r.createdAt)}</td></tr>
<tr><td class="label-cell">总任务数</td><td>${r.taskSummaries.length}</td></tr>
<tr><td class="label-cell">总请求数</td><td>${s.totalRequests}</td></tr>
<tr><td class="label-cell">实际总耗时</td><td>${s.durationActual != null ? s.durationActual.toFixed(1) + 's' : '-'}</td></tr>
</table>
</div>
<div class="section">
<h2>核心指标</h2>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value">${s.totalRequests}</div><div class="metric-label">总请求数</div></div>
<div class="metric-item"><div class="metric-value">${s.successCount}</div><div class="metric-label">总成功数</div></div>
<div class="metric-item"><div class="metric-value">${s.failCount}</div><div class="metric-label">总失败数</div></div>
<div class="metric-item"><div class="metric-value">${(s.errorRate * 100).toFixed(2)}%</div><div class="metric-label">整体错误率</div></div>
<div class="metric-item"><div class="metric-value">${s.actualTps.toFixed(1)}</div><div class="metric-label">整体吞吐量</div></div>
<div class="metric-item"><div class="metric-value">${s.peakTps.toFixed(1)}</div><div class="metric-label">峰值吞吐量</div></div>
<div class="metric-item"><div class="metric-value">${s.avgResponseTime.toFixed(1)}ms</div><div class="metric-label">平均响应时间</div></div>
<div class="metric-item"><div class="metric-value">${s.p95ResponseTime.toFixed(1)}ms</div><div class="metric-label">P95 响应时间</div></div>
<div class="metric-item"><div class="metric-value">${s.stdDev.toFixed(1)}ms</div><div class="metric-label">标准差</div></div>
<div class="metric-item"><div class="metric-value">${s.apdex.toFixed(2)}</div><div class="metric-label">Apdex</div></div>
</div>
</div>
<div class="section">
<h2>响应时间分位数</h2>
<table>
<tr><th>分位数</th><th>值 (ms)</th></tr>
<tr><td>P50</td><td>${s.p50ResponseTime.toFixed(1)}</td></tr>
<tr><td>P90</td><td>${s.p90ResponseTime.toFixed(1)}</td></tr>
<tr><td>P95</td><td>${s.p95ResponseTime.toFixed(1)}</td></tr>
<tr><td>P99</td><td>${s.p99ResponseTime.toFixed(1)}</td></tr>
</table>
</div>
<div class="section">
<h2>状态码分布</h2>
<table>
<tr><th>状态码</th><th>数量</th><th>占比</th></tr>
<tr><td>2xx</td><td>${s.status2xx}</td><td>${((s.status2xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>3xx</td><td>${s.status3xx}</td><td>${((s.status3xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>4xx</td><td>${s.status4xx}</td><td>${((s.status4xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>5xx</td><td>${s.status5xx}</td><td>${((s.status5xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
</table>
</div>
<div class="section">
<h2>各任务明细(${r.taskSummaries.length} 个任务)</h2>
<table>
<tr><th>任务名称</th><th>状态</th><th>请求数</th><th>成功</th><th>失败</th><th>错误率</th><th>平均响应(ms)</th><th>TPS</th></tr>
${r.taskSummaries.map(t => `
<tr>
<td>${t.taskName}</td>
<td>${statusLabel(t.status)}</td>
<td>${t.totalRequests}</td>
<td>${t.successCount}</td>
<td>${t.failCount}</td>
<td>${(t.errorRate * 100).toFixed(1)}%</td>
<td>${t.avgResponseTime.toFixed(1)}</td>
<td>${t.actualTps.toFixed(1)}</td>
</tr>`).join('')}
</table>
</div>
`
}
/** 分位数表行 */
......
此差异已折叠。
......@@ -559,6 +559,23 @@
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务描述,可选" />
</el-form-item>
<el-divider content-position="left">请求/响应设置</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item>
<template #label>
请求详情采集
<FieldTip content="开启后将记录每个请求的完整报文(请求头/体、响应头/体),可在报告页「请求详情」Tab 查看。关闭(默认)不记录任何请求明细,零性能开销" />
</template>
<el-select v-model="form.requestDetailEnabled" style="width: 100%">
<el-option label="关闭(默认)" value="off" />
<el-option label="全部请求" value="on" />
<el-option label="仅错误请求" value="errors_only" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-collapse v-if="form.taskType === 'single'">
<el-collapse-item name="uniqueFields" title="唯一性字段配置(可选)">
<div class="unique-field-tip">
......@@ -643,7 +660,7 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Refresh, Upload, Download, Delete, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listProjects, parseCurl } from '@/api/performance'
import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, CurlParseResult, UniqueFieldRule, PerformanceProject, PerfScenarioType, LoopType, ErrorAction, ThinkTimeDistribution } from '@/types/performance'
import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, CurlParseResult, UniqueFieldRule, PerformanceProject, PerfScenarioType, LoopType, ErrorAction, ThinkTimeDistribution, RequestDetailMode } from '@/types/performance'
import FieldTip from '@/components/FieldTip.vue'
import * as XLSX from 'xlsx'
......@@ -720,6 +737,8 @@ interface TaskForm {
csvParameterizationEnabled: boolean
csvContent: string
csvVariableMapping: string
/** 请求详情采集模式 */
requestDetailEnabled: RequestDetailMode
}
/** endurance 场景接口表单项 */
......@@ -791,6 +810,7 @@ const defaultForm = (): TaskForm => ({
csvParameterizationEnabled: false,
csvContent: '',
csvVariableMapping: '',
requestDetailEnabled: 'off',
})
const form = reactive<TaskForm>(defaultForm())
......@@ -912,6 +932,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
csvParameterizationEnabled: fullTask.csvParameterizationEnabled ?? false,
csvContent: fullTask.csvContent || '',
csvVariableMapping: fullTask.csvVariableMapping ? JSON.stringify(fullTask.csvVariableMapping, null, 2) : '',
requestDetailEnabled: fullTask.requestDetailEnabled || 'off',
})
dialogVisible.value = true
}
......@@ -1207,6 +1228,7 @@ async function handleSave() {
csvContent: form.csvParameterizationEnabled ? form.csvContent : null,
csvVariableMapping: form.csvParameterizationEnabled && form.csvVariableMapping
? safeJsonParse(form.csvVariableMapping) : null,
requestDetailEnabled: form.requestDetailEnabled || 'off',
}
if (editingTask.value) {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论