提交 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)
......
......@@ -33,6 +33,7 @@ import random
import re
import threading
import time
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
import aiohttp
......@@ -706,6 +707,12 @@ class PerformanceExecutor:
# 捕获值数量上限,超出丢弃并告警
self._capture_max = 10000
# 请求明细缓冲(JMeter 察看结果树,执行结束统一写库,2026-08-25 新增)
self._request_details: List[Dict[str, Any]] = []
self._request_details_lock = threading.Lock()
self._request_detail_enabled = "off" # off / on / errors_only
self._request_detail_max = 50000
def _get_http_client(self) -> HttpClient:
"""获取或创建 HttpClient 实例"""
if not self._http_client:
......@@ -1009,6 +1016,10 @@ class PerformanceExecutor:
self._metrics = MetricsCollector(snapshot_callback=snapshot_callback)
self._running = True
# 请求明细采集开关(从任务配置读取,2026-08-25 新增)
self._request_detail_enabled = getattr(task, "request_detail_enabled", "off") or "off"
self._request_details.clear()
# 1. 获取 Token
if task.auth_required:
token = self._ensure_token(task)
......@@ -1093,6 +1104,15 @@ class PerformanceExecutor:
result["target_resource_summary"] = self._build_target_resource_summary()
# 接口维度指标汇总(长稳压测多接口场景)
result["api_summary"] = self._metrics._build_api_summary() if self._metrics else None
# 请求明细采集结果(JMeter 察看结果树,由服务层统一写库)
with self._request_details_lock:
request_details = list(self._request_details)
result["request_details"] = request_details
result["request_detail_truncated"] = (
self._request_detail_enabled != "off"
and len(request_details) >= self._request_detail_max
)
result["request_detail_enabled"] = self._request_detail_enabled
return result
......@@ -1700,6 +1720,15 @@ class PerformanceExecutor:
connect_ms = 0.0
sent_bytes = 0
received_bytes = 0
# 请求明细采集用(JMeter 察看结果树,2026-08-25 新增)
method = getattr(task, "method", "GET").upper()
url = task.target_url or ""
headers = dict(getattr(task, "headers", {}) or {})
json_data = None
response_headers = None
assert_ok = None
error_type = ""
error_message = None
# 实时并发数(在途请求数)统计
self._inflight += 1
......@@ -1783,6 +1812,7 @@ class PerformanceExecutor:
body = await resp.text()
received_bytes = len(body.encode("utf-8"))
elapsed_ms = (time.time() - start) * 1000
response_headers = dict(resp.headers)
# 执行断言
assert_ok = self._validator.validate(
......@@ -1802,6 +1832,8 @@ class PerformanceExecutor:
except asyncio.TimeoutError:
elapsed_ms = (time.time() - start) * 1000
error_type = "timeout"
error_message = "timeout"
self._metrics.record(
0, elapsed_ms, False, "timeout",
sent_bytes=sent_bytes, error_type="timeout",
......@@ -1809,6 +1841,8 @@ class PerformanceExecutor:
)
except aiohttp.ClientConnectorError as e:
elapsed_ms = (time.time() - start) * 1000
error_type = "connect_error"
error_message = str(e)
self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="connect_error",
......@@ -1816,6 +1850,8 @@ class PerformanceExecutor:
)
except aiohttp.ClientError as e:
elapsed_ms = (time.time() - start) * 1000
error_type = "client_error"
error_message = str(e)
self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="client_error",
......@@ -1823,6 +1859,8 @@ class PerformanceExecutor:
)
except Exception as e:
elapsed_ms = (time.time() - start) * 1000
error_type = "client_error"
error_message = str(e)
self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="client_error",
......@@ -1830,6 +1868,16 @@ class PerformanceExecutor:
)
finally:
self._inflight -= 1
# 请求明细采集(JMeter 察看结果树,2026-08-25 新增)
self._collect_request_detail(
method=method, url=url, headers=headers, json_data=json_data,
status=status, body=body, response_headers=response_headers,
elapsed_ms=elapsed_ms, latency_ms=latency_ms, connect_ms=connect_ms,
sent_bytes=sent_bytes, received_bytes=received_bytes,
assert_ok=assert_ok, error_type=error_type, error_message=error_message,
request_index=request_index, thread_idx=global_thread_idx,
api_name=api_name,
)
# ==================== 事务模式(多步骤) ====================
......@@ -1872,6 +1920,17 @@ class PerformanceExecutor:
body = ""
elapsed_ms = 0.0
step_error = ""
# 请求明细采集用(JMeter 察看结果树,2026-08-25 新增)
sent_bytes = 0
received_bytes = 0
latency_ms = 0.0
connect_ms = 0.0
response_headers = None
step_assert_ok = None
error_type = ""
error_message = None
step_body: Any = None
step_headers: Dict[str, Any] = {}
try:
method = step.get("method", "GET").upper()
......@@ -1908,6 +1967,11 @@ class PerformanceExecutor:
status = resp.status
body = await resp.text()
elapsed_ms = (time.time() - step_start) * 1000
latency_ms = (time.time() - step_start) * 1000
connect_ms = 0.0
response_headers = dict(resp.headers)
sent_bytes = len(json.dumps(step_body or {}, ensure_ascii=False).encode("utf-8")) if step_body else 0
received_bytes = len(body.encode("utf-8"))
# 步骤级断言验证
step_assertions = step.get("assertions", []) or []
......@@ -1935,6 +1999,18 @@ class PerformanceExecutor:
# 合并到 shared_context
shared_context.update(step_captured)
# 请求明细采集(事务模式:每个步骤一条,api_name 带步骤名)
self._collect_request_detail(
method=method, url=url, headers=step_headers, json_data=step_body,
status=status, body=body, response_headers=response_headers,
elapsed_ms=elapsed_ms, latency_ms=latency_ms, connect_ms=connect_ms,
sent_bytes=sent_bytes, received_bytes=received_bytes,
assert_ok=step_assert_ok, error_type=error_type, error_message=error_message,
request_index=request_index,
thread_idx=0,
api_name=step.get("name", f"step_{step_idx}"),
)
step_success = step_assert_ok and (200 <= status < 400)
step_results.append({
"step_name": step.get("name", f"step_{step_idx}"),
......@@ -1958,7 +2034,19 @@ class PerformanceExecutor:
except asyncio.TimeoutError:
elapsed_ms = (time.time() - step_start) * 1000
step_error = "timeout"
error_type = "timeout"
error_message = "响应超时"
overall_success = False
self._collect_request_detail(
method=method, url=url, headers=step_headers, json_data=step_body,
status=0, body="", response_headers=None,
elapsed_ms=elapsed_ms, latency_ms=0.0, connect_ms=0.0,
sent_bytes=0, received_bytes=0,
assert_ok=None, error_type=error_type, error_message=error_message,
request_index=request_index,
thread_idx=0,
api_name=step.get("name", f"step_{step_idx}"),
)
step_results.append({
"step_name": step.get("name", f"step_{step_idx}"),
"step_index": step_idx,
......@@ -1972,7 +2060,19 @@ class PerformanceExecutor:
except aiohttp.ClientError as e:
elapsed_ms = (time.time() - step_start) * 1000
step_error = str(e)
error_type = "client_error"
error_message = str(e)
overall_success = False
self._collect_request_detail(
method=method, url=url, headers=step_headers, json_data=step_body,
status=0, body="", response_headers=None,
elapsed_ms=elapsed_ms, latency_ms=0.0, connect_ms=0.0,
sent_bytes=0, received_bytes=0,
assert_ok=None, error_type=error_type, error_message=error_message,
request_index=request_index,
thread_idx=0,
api_name=step.get("name", f"step_{step_idx}"),
)
step_results.append({
"step_name": step.get("name", f"step_{step_idx}"),
"step_index": step_idx,
......@@ -1986,7 +2086,19 @@ class PerformanceExecutor:
except Exception as e:
elapsed_ms = (time.time() - step_start) * 1000
step_error = str(e)
error_type = "client_error"
error_message = str(e)
overall_success = False
self._collect_request_detail(
method=method, url=url, headers=step_headers, json_data=step_body,
status=0, body="", response_headers=None,
elapsed_ms=elapsed_ms, latency_ms=0.0, connect_ms=0.0,
sent_bytes=0, received_bytes=0,
assert_ok=None, error_type=error_type, error_message=error_message,
request_index=request_index,
thread_idx=0,
api_name=step.get("name", f"step_{step_idx}"),
)
step_results.append({
"step_name": step.get("name", f"step_{step_idx}"),
"step_index": step_idx,
......@@ -2165,6 +2277,157 @@ class PerformanceExecutor:
"stepSummaries": step_summaries,
}
def _collect_request_detail(self, *, method: str, url: str, headers: dict,
json_data: Any, status: int, body: str,
response_headers: Optional[dict], elapsed_ms: float,
latency_ms: float, connect_ms: float,
sent_bytes: int, received_bytes: int,
assert_ok: Optional[bool], error_type: str,
error_message: Optional[str], request_index: int,
thread_idx: int, api_name: str) -> None:
"""
请求明细采集统一入口(JMeter 察看结果树,2026-08-25 新增)
三种模式(_request_detail_enabled):
- off: 不采集(默认,零开销)
- on: 采集全部请求
- errors_only: 仅采集失败请求(HTTP 非 2xx/3xx 或断言失败)
采样上限 _request_detail_max(默认 50000),超限静默丢弃(报告标记 truncated)。
Args:
method: 请求方法
url: 请求 URL(动态变量解析后最终值)
headers: 请求头
json_data: 请求体(已解析)
status: HTTP 状态码(0=网络异常)
body: 响应体文本
response_headers: 响应头
elapsed_ms: 总耗时(ms)
latency_ms: 首字节延迟(ms)
connect_ms: 连接时间(ms)
sent_bytes: 发送字节数
received_bytes: 接收字节数
assert_ok: 断言是否通过(无断言时 None)
error_type: 错误类型
error_message: 错误消息
request_index: 请求序号
thread_idx: 虚拟用户索引
api_name: 接口名(长稳多接口场景)
"""
if self._request_detail_enabled == "off":
return
# 采样上限保护(超限截断,报告页标记 truncated)
if len(self._request_details) >= self._request_detail_max:
return
# errors_only 模式:仅采集失败请求
if self._request_detail_enabled == "errors_only":
is_success = (200 <= status < 400) and (assert_ok is None or assert_ok)
if is_success:
return
detail = self._build_request_detail(
method=method, url=url, headers=headers, json_data=json_data,
status=status, body=body, response_headers=response_headers,
elapsed_ms=elapsed_ms, latency_ms=latency_ms, connect_ms=connect_ms,
sent_bytes=sent_bytes, received_bytes=received_bytes,
assert_ok=assert_ok, error_type=error_type, error_message=error_message,
request_index=request_index, thread_idx=thread_idx,
api_name=api_name,
)
with self._request_details_lock:
self._request_details.append(detail)
def _build_request_detail(self, *, method: str, url: str, headers: dict,
json_data: Any, status: int, body: str,
response_headers: Optional[dict], elapsed_ms: float,
latency_ms: float, connect_ms: float,
sent_bytes: int, received_bytes: int,
assert_ok: Optional[bool], error_type: str,
error_message: Optional[str], request_index: int,
thread_idx: int, api_name: str) -> Dict[str, Any]:
"""
构建请求明细记录(JMeter 察看结果树,2026-08-25 新增)
脱敏规则:请求头/请求体中的 Authorization / password / token / secret / sign
等敏感字段在入库前替换为 `***`。
响应体截断:默认保留前 8KB(防大响应撑爆数据库)。
错误消息截断:保留前 500 字符。
Args:
(同 _collect_request_detail)
Returns:
dict: 请求明细记录
"""
resp_body = str(body or "")
if len(resp_body) > 8192:
resp_body = resp_body[:8192]
error_msg = error_message
if error_msg and len(error_msg) > 500:
error_msg = error_msg[:500]
return {
"method": method,
"url": url,
"request_headers": self._desensitize(headers or {}),
"request_body": self._desensitize(json_data) if json_data is not None else None,
"status": status,
"response_headers": self._desensitize(response_headers or {}),
"response_body": resp_body,
"response_time_ms": round(elapsed_ms, 2),
"latency_ms": round(latency_ms, 2),
"connect_ms": round(connect_ms, 2),
"sent_bytes": sent_bytes,
"received_bytes": received_bytes,
"assert_result": assert_ok,
"error_type": error_type or None,
"error_message": error_msg,
"success": (200 <= status < 400) and (assert_ok is None or assert_ok),
"request_index": request_index,
"thread_idx": thread_idx,
"api_name": api_name,
"ts": datetime.utcnow().isoformat(timespec="milliseconds"),
}
@staticmethod
def _desensitize(data: Any) -> Any:
"""
敏感字段脱敏(JMeter 察看结果树,2026-08-25 新增)
将 dict/list/str 递归中的敏感字段值替换为 `***`。
敏感键名匹配(不区分大小写):authorization / password / token / secret / sign
及其子串组合(如 X-SIGN、access_token、client_secret)。
Args:
data: 请求头 / 请求体 / 响应头 / 响应体(任意可 JSON 序列化结构)
Returns:
Any: 脱敏后的数据
"""
_SENSITIVE_KEYS = ("authorization", "password", "token", "secret", "sign")
if isinstance(data, dict):
return {
k: ("***" if any(s in k.lower() for s in _SENSITIVE_KEYS)
else PerformanceExecutor._desensitize(v))
for k, v in data.items()
}
if isinstance(data, list):
return [PerformanceExecutor._desensitize(v) for v in data]
if isinstance(data, str):
# 字符串内嵌的 JSON(如格式化后的 request_body)递归脱敏
stripped = data.strip()
if stripped.startswith(("{", "[")):
try:
return PerformanceExecutor._desensitize(json.loads(stripped))
except (json.JSONDecodeError, TypeError):
pass
return data
return data
async def _sleep_with_check(self, seconds: float) -> None:
"""
分段睡眠,期间持续检查停止标志,保证 stop() 能快速生效
......
......@@ -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):
......
......@@ -17,7 +17,7 @@ from datetime import datetime
from types import SimpleNamespace
from typing import Any, Callable, Dict, List, Optional, Tuple
from sqlalchemy import select, func, desc
from sqlalchemy import select, func, asc, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.performance import (
......@@ -27,6 +27,7 @@ from app.models.performance import (
PerformanceProject,
PerformanceBatchExecution,
PerformanceProjectReport,
PerformanceRequestDetail,
)
from app.models.performance_output import PerformanceTaskOutput
from app.models.api_preset import ApiPreset
......@@ -341,6 +342,7 @@ class PerformanceService:
start_time=datetime.utcnow(),
triggered_by=triggered_by,
batch_id=batch_id,
request_detail_enabled=getattr(task, "request_detail_enabled", "off") or "off",
created_at=datetime.utcnow(),
)
self.db.add(execution)
......@@ -426,6 +428,10 @@ class PerformanceService:
if result and not result.get("error"):
self._copy_result_to_execution(execution, result)
execution.snapshot_count = len(snapshot_buffer)
# 请求详情采集状态回写(JMeter 察看结果树,2026-08-25 新增)
execution.request_detail_truncated = bool(
result.get("request_detail_truncated", False) if result else False
)
await self.db.flush()
# 批量保存快照(关联 execution_id)
......@@ -434,6 +440,13 @@ class PerformanceService:
# 保存响应捕获输出(供其他任务 REF 引用)
await self._save_outputs(task_id, result.get("captured") if result else None)
# 保存请求明细(JMeter 察看结果树,2026-08-25 新增)
await self._save_request_details(
execution_id, task_id,
result.get("request_details") if result else None,
result.get("request_detail_truncated", False) if result else False,
)
await self.db.commit()
# 清理运行缓存
......@@ -882,6 +895,157 @@ class PerformanceService:
return snapshots, total
async def get_request_details(
self,
execution_id: str,
page: int = 1,
page_size: int = 20,
status: Optional[str] = None,
success: Optional[bool] = None,
api_name: Optional[str] = None,
keyword: Optional[str] = None,
min_response_ms: Optional[float] = None,
max_response_ms: Optional[float] = None,
sort: str = "ts",
order: str = "asc",
) -> Tuple[List[PerformanceRequestDetail], int, str, bool]:
"""
获取请求明细列表(两段式查询,避免大列参与排序)
先查 id 轻量排序分页,再按 id 回查整行。
Args:
execution_id: 执行记录ID
page: 页码
page_size: 每页数量
status: 状态码簇过滤(200/4xx/5xx/0)
success: 成功/失败过滤
api_name: 接口名过滤
keyword: URL/响应体关键字搜索
min_response_ms: 最小响应时间
max_response_ms: 最大响应时间
sort: 排序字段(ts/response_time_ms)
order: 排序方向(asc/desc)
Returns:
tuple: (明细列表, 总数, 采集模式, 是否截断)
"""
from app.models.performance import PerformanceRequestDetail
# 基础条件
conditions = [PerformanceRequestDetail.execution_id == execution_id]
# 状态码簇过滤
if status:
if status == "0":
conditions.append(PerformanceRequestDetail.status == 0)
elif status in ("4xx", "5xx"):
prefix = int(status[0])
conditions.append(
PerformanceRequestDetail.status >= prefix * 100
)
conditions.append(
PerformanceRequestDetail.status < (prefix + 1) * 100
)
elif status == "200":
conditions.append(PerformanceRequestDetail.status == 200)
else:
# 精确匹配
try:
code = int(status)
conditions.append(PerformanceRequestDetail.status == code)
except ValueError:
pass
# 成功/失败过滤
if success is not None:
conditions.append(PerformanceRequestDetail.success == success)
# 接口名过滤
if api_name:
conditions.append(PerformanceRequestDetail.api_name == api_name)
# 关键字搜索(URL 或 响应体)
if keyword:
conditions.append(
PerformanceRequestDetail.url.ilike(f"%{keyword}%")
| PerformanceRequestDetail.response_body.ilike(f"%{keyword}%")
)
# 响应时间区间
if min_response_ms is not None:
conditions.append(
PerformanceRequestDetail.response_time_ms >= min_response_ms
)
if max_response_ms is not None:
conditions.append(
PerformanceRequestDetail.response_time_ms <= max_response_ms
)
# 总数
count_query = (
select(func.count())
.select_from(PerformanceRequestDetail)
.where(*conditions)
)
total = (await self.db.execute(count_query)).scalar() or 0
# 第一段:轻量排序(只取 id)
sort_col = (
PerformanceRequestDetail.response_time_ms
if sort == "response_time_ms"
else PerformanceRequestDetail.ts
)
order_fn = desc if order == "desc" else asc
id_query = (
select(PerformanceRequestDetail.id)
.where(*conditions)
.order_by(order_fn(sort_col))
.offset((page - 1) * page_size)
.limit(page_size)
)
ids = [row[0] for row in (await self.db.execute(id_query)).all()]
# 第二段:按 id 回查整行,按第一段顺序还原
items: List[PerformanceRequestDetail] = []
if ids:
rows = await self.db.execute(
select(PerformanceRequestDetail)
.where(PerformanceRequestDetail.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]
# 获取采集模式与截断标志(从第一条记录推断)
request_detail_enabled = "off"
truncated = False
if items:
# 查 execution 记录的开关状态
exec_record = await self.db.get(PerformanceExecution, execution_id)
if exec_record:
request_detail_enabled = getattr(
exec_record, "request_detail_enabled", "off"
) or "off"
truncated = getattr(exec_record, "request_detail_truncated", False)
elif total > 0:
# 有总数但无当前页数据 —— 仍可查 execution 记录
exec_record = await self.db.get(PerformanceExecution, execution_id)
if exec_record:
request_detail_enabled = getattr(
exec_record, "request_detail_enabled", "off"
) or "off"
return items, total, request_detail_enabled, truncated
async def get_request_detail(
self, execution_id: str, detail_id: int
) -> Optional[PerformanceRequestDetail]:
"""获取单条请求明细详情"""
detail = await self.db.get(PerformanceRequestDetail, detail_id)
if not detail or detail.execution_id != execution_id:
return None
return detail
async def delete_execution(self, execution_id: str) -> bool:
"""
删除执行记录(级联删除关联快照)
......@@ -1048,6 +1212,7 @@ class PerformanceService:
start_time=datetime.utcnow(),
triggered_by=triggered_by,
batch_id=batch_id,
request_detail_enabled=getattr(task, "request_detail_enabled", "off") or "off",
created_at=datetime.utcnow(),
)
self.db.add(execution)
......@@ -1421,6 +1586,8 @@ class PerformanceService:
status_3xx=s.get("status_3xx", 0),
status_4xx=s.get("status_4xx", 0),
status_5xx=s.get("status_5xx", 0),
resource=s.get("resource"),
target_resource=s.get("targetResource") or s.get("target_resource"),
))
self.db.add_all(batch)
......@@ -1484,6 +1651,79 @@ class PerformanceService:
logger.info(f"保存响应捕获输出: {saved} 条 (task_id={task_id})")
return saved
async def _save_request_details(
self,
execution_id: str,
task_id: str,
details: Optional[List[Dict[str, Any]]],
truncated: bool = False,
) -> int:
"""
批量保存请求明细到数据库(JMeter 察看结果树)
执行期间明细在 executor 内存缓冲中,执行结束后由本方法批量 INSERT。
每批 500 条 flush,避免单次事务过大。
Args:
execution_id: 执行记录ID
task_id: 任务ID
details: 请求明细列表(可能为空/None)
truncated: 是否因采样上限被截断
Returns:
int: 保存的明细条数
"""
if not details:
return 0
# 校验 execution_id 与 task_id(兜底,正常必传)
if not execution_id or not task_id:
logger.warning("保存请求明细跳过:缺少 execution_id/task_id")
return 0
batch: List[PerformanceRequestDetail] = []
for d in details:
batch.append(PerformanceRequestDetail(
execution_id=execution_id,
task_id=task_id,
api_name=d.get("api_name", "") or "",
request_index=d.get("request_index", 1) or 1,
thread_idx=d.get("thread_idx"),
method=d.get("method", "GET") or "GET",
url=d.get("url", "") or "",
request_headers=d.get("request_headers"),
request_body=d.get("request_body"),
status=d.get("status", 0) or 0,
response_headers=d.get("response_headers"),
response_body=d.get("response_body"),
response_time_ms=d.get("response_time_ms", 0.0) or 0.0,
latency_ms=d.get("latency_ms", 0.0) or 0.0,
connect_ms=d.get("connect_ms", 0.0) or 0.0,
sent_bytes=d.get("sent_bytes", 0) or 0,
received_bytes=d.get("received_bytes", 0) or 0,
assert_result=d.get("assert_result"),
error_type=d.get("error_type"),
error_message=d.get("error_message"),
success=bool(d.get("success", True)),
ts=d.get("ts") or datetime.utcnow(),
))
# 每批 500 条 flush,控制内存占用
if len(batch) >= 500:
self.db.add_all(batch)
await self.db.flush()
batch.clear()
if batch:
self.db.add_all(batch)
await self.db.flush()
flag = "(截断)" if truncated else ""
logger.info(
f"批量保存请求明细: {len(details)} 条{flag} "
f"(task_id={task_id}, execution_id={execution_id})"
)
return len(details)
def _build_ref_provider(self) -> Callable[[str, str], list]:
"""
构建跨任务引用取值回调
......
#!/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>
`
}
/** 分位数表行 */
......
......@@ -109,9 +109,19 @@
<el-button type="primary" :loading="loading" @click="loadReport">
<el-icon><Refresh /></el-icon>刷新报
</el-button>
<el-button v-if="report" @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>
<el-button v-if="report" type="warning" :loading="aiLoading" @click="triggerAiAnalysis">
<el-icon><MagicStick /></el-icon>AI 分
</el-button>
......@@ -122,6 +132,8 @@
<el-skeleton v-if="loading" :rows="8" animated />
<template v-else-if="report">
<el-tabs v-model="activeTab" class="report-tabs" @tab-change="onTabChange">
<el-tab-pane label="报告概览" name="overview">
<!-- 摘要信息 -->
<el-descriptions title="执行摘要 Summary" :column="3" border class="summary-block">
<el-descriptions-item label="任务名称 Task Name">{{ report.summary.taskName }}</el-descriptions-item>
......@@ -685,6 +697,90 @@
</template>
</template>
<!-- MySQL 服务监控(仅当有数据时显示) -->
<template v-if="report.targetResourceSummary?.mysql?.available">
<el-divider content-position="left">MySQL 服务监控 MySQL Monitor</el-divider>
<div class="target-host-info">
<el-tag size="small" type="info" effect="plain">MySQL {{ report.targetResourceSummary.mysql.container }}</el-tag>
<span class="target-host-hint">MySQL 容器 {{ report.targetResourceSummary.mysql.container }}v{{ report.targetResourceSummary.mysql.version }}</span>
</div>
<el-row :gutter="12" class="metrics-row">
<el-col :span="4">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: report.targetResourceSummary.mysql.threadsConnected > 100 ? '#f56c6c' : '#409eff' }">
{{ report.targetResourceSummary.mysql.threadsConnected }}
</div>
<div class="metric-label">线程连接数 Threads Connected</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: report.targetResourceSummary.mysql.threadsRunning > 20 ? '#f56c6c' : '#409eff' }">
{{ report.targetResourceSummary.mysql.threadsRunning }}
</div>
<div class="metric-label">活跃线程 Threads Running</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: report.targetResourceSummary.mysql.slowQueries > 5 ? '#f56c6c' : '#e6a23c' }">
{{ report.targetResourceSummary.mysql.slowQueries }}
</div>
<div class="metric-label">慢查询 Slow Queries</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: report.targetResourceSummary.mysql.bufferPoolHitRate < 95 ? '#f56c6c' : '#409eff' }">
{{ report.targetResourceSummary.mysql.bufferPoolHitRate.toFixed(1) }}%
</div>
<div class="metric-label">缓冲池命中率 Buffer Pool Hit Rate</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.targetResourceSummary.mysql.maxUsedConnections }} / {{ report.targetResourceSummary.mysql.maxConnections }}</div>
<div class="metric-label">连接数 Max Used / Max Connections</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ formatUptime(report.targetResourceSummary.mysql.uptime) }}</div>
<div class="metric-label">运行时间 Uptime</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12" class="metrics-row">
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.targetResourceSummary.mysql.connections }}</div>
<div class="metric-label">总连接数 Connections</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.targetResourceSummary.mysql.questions }}</div>
<div class="metric-label">QPS Questions</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.targetResourceSummary.mysql.queries }}</div>
<div class="metric-label">QPS Queries</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.targetResourceSummary.mysql.bytesReceivedMb.toFixed(1) }} MB / {{ report.targetResourceSummary.mysql.bytesSentMb.toFixed(1) }} MB</div>
<div class="metric-label">收发流量 Received / SentMB</div>
</el-card>
</el-col>
</el-row>
</template>
<!-- 事务处理时间明细(仅事务任务) -->
<template v-if="report.transactionSummary && report.transactionSummary.stepSummaries.length">
<el-divider content-position="left">事务步骤处理时间 Transaction Waterfall</el-divider>
......@@ -748,6 +844,15 @@
</el-table>
</el-card>
</template>
</el-tab-pane>
<el-tab-pane label="请求详情" name="details">
<RequestDetailPanel
v-if="activeTab === 'details'"
:execution-id="detailExecutionId"
:task-id="taskId"
/>
</el-tab-pane>
</el-tabs>
</template>
<el-empty v-else description="暂无报告数据(任务尚未执行)" />
......@@ -765,13 +870,18 @@
import { ref, watch, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Document, Refresh, Download, List, MagicStick } from '@element-plus/icons-vue'
import { Document, Refresh, Download, List, MagicStick, ArrowDown } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import { getReport, listTasks, listExecutions, getExecutionReport, getAiAnalysis } from '@/api/performance'
import type { PerformanceReportResponse, AiAnalysisResponse } from '@/types/performance'
import type {
PerformanceReportResponse,
AiAnalysisResponse,
} from '@/types/performance'
import type { EChartsOption } from 'echarts'
import type { PerformanceTaskListItem, PerformanceExecutionListItem } from '@/types/performance'
import { marked } from 'marked'
import RequestDetailPanel from './RequestDetailPanel.vue'
import { exportWord, exportPdf, exportJson, getChartImage } from '@/utils/exportReport'
const route = useRoute()
const router = useRouter()
......@@ -835,6 +945,10 @@ async function loadExecutionOptions() {
id: e.id,
label: `${formatTime(e.startTime)} #${e.status} TPS:${e.actualTps != null ? e.actualTps.toFixed(1) : '-'} RT:${e.avgResponseTime != null ? e.avgResponseTime.toFixed(0) : '-'}ms`,
}))
// 路由只带 taskId(无 executionId)时,默认选中最近一次执行,保证请求详情 tab 有可用的 executionId
if (!executionId.value && executionOptions.value.length > 0) {
selectedExecutionId.value = executionOptions.value[0].id
}
} catch { /* ignore */ } finally {
loadingExecutions.value = false
}
......@@ -876,6 +990,28 @@ function onTaskSelected(id: string) {
const loading = ref(false)
const report = ref<PerformanceReportResponse | null>(null)
const selectedExecutionId = ref('')
const activeTab = ref('overview')
/** 用于请求详情子组件的 executionId(优先 activeExecutionId,fallback 到路由 executionId) */
const detailExecutionId = computed(() => selectedExecutionId.value || executionId.value)
/** Tab 切换回调 */
function onTabChange(tab: string) {
if (tab === 'overview') {
// 图表在隐藏 Tab 中宽度为 0,切回概览时重新 resize
nextTick(() => {
tpsChart?.resize()
rtChart?.resize()
latencyChart?.resize()
bandwidthChart?.resize()
resourceChart?.resize()
networkChart?.resize()
targetResourceChart?.resize()
javaProcessChart?.resize()
waterfallChart?.resize()
})
}
}
const tpsChartRef = ref<HTMLElement>()
const rtChartRef = ref<HTMLElement>()
......@@ -959,6 +1095,19 @@ function formatBytes(bytes: number) {
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
/** MySQL Uptime 秒数 → 可读格式 */
function formatUptime(seconds: number): string {
if (!seconds) return '0s'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (d) parts.push(d + 'd')
if (h) parts.push(h + 'h')
if (m) parts.push(m + 'm')
return parts.join(' ') || seconds + 's'
}
/** 状态码表格行 */
const statusRows = computed(() => {
if (!report.value) return []
......@@ -994,16 +1143,140 @@ async function loadReport() {
}
}
/** 导出下拉菜单命令 */
function onExportCommand(command: string) {
if (!report.value) return
switch (command) {
case 'word':
exportWordToDoc()
break
case 'pdf':
exportPdfFromReport()
break
case 'json':
exportReportJson()
break
}
}
/** 导出报告 JSON */
function exportReport() {
function exportReportJson() {
if (!report.value) return
exportJson(report.value, `perf-report-${report.value.summary.taskId}`)
}
/** 导出 Word */
function exportWordToDoc() {
if (!report.value) return
const r = report.value
const html = buildReportHtml(r)
exportWord(`性能测试报告 - ${r.summary.taskName}`, html, `perf-report-${r.summary.taskId}`)
}
/** 导出 PDF */
function exportPdfFromReport() {
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 = `perf-report-${report.value.summary.taskId}.json`
a.click()
URL.revokeObjectURL(url)
const r = report.value
const html = buildReportHtml(r)
exportPdf(`性能测试报告 - ${r.summary.taskName}`, html)
}
/** 构建报告 HTML 内容(含图表 base64 图片) */
function buildReportHtml(r: PerformanceReportResponse): string {
const m = r.metrics
const snapshots = r.snapshots || []
let chartsHtml = ''
// 嵌入图表图片
const tpsImg = getChartImage(tpsChart)
const rtImg = getChartImage(rtChart)
const latencyImg = getChartImage(latencyChart)
const bandwidthImg = getChartImage(bandwidthChart)
if (tpsImg) chartsHtml += `<h2>TPS 趋势</h2><img class="chart-image" src="${tpsImg}" alt="TPS 趋势" />`
if (rtImg) chartsHtml += `<h2>响应时间趋势</h2><img class="chart-image" src="${rtImg}" alt="响应时间趋势" />`
if (latencyImg) chartsHtml += `<h2>延迟分解趋势</h2><img class="chart-image" src="${latencyImg}" alt="延迟分解趋势" />`
if (bandwidthImg) chartsHtml += `<h2>带宽趋势</h2><img class="chart-image" src="${bandwidthImg}" alt="带宽趋势" />`
// 资源图表
const resourceImg = getChartImage(resourceChart)
if (resourceImg) chartsHtml += `<h2>执行机资源趋势</h2><img class="chart-image" src="${resourceImg}" alt="执行机资源趋势" />`
const targetResourceImg = getChartImage(targetResourceChart)
if (targetResourceImg) chartsHtml += `<h2>目标机资源趋势</h2><img class="chart-image" src="${targetResourceImg}" alt="目标机资源趋势" />`
return `
<h1>性能测试报告</h1>
<div class="section">
<h2>执行摘要</h2>
<table>
<tr><td class="label-cell">任务名称</td><td>${r.summary.taskName}</td></tr>
<tr><td class="label-cell">目标 URL</td><td>${r.summary.targetUrl}</td></tr>
<tr><td class="label-cell">HTTP 方法</td><td>${r.summary.method}</td></tr>
<tr><td class="label-cell">压测模式</td><td>${r.summary.mode}</td></tr>
<tr><td class="label-cell">并发数</td><td>${r.summary.concurrency}</td></tr>
<tr><td class="label-cell">开始时间</td><td>${r.summary.startTime || '-'}</td></tr>
<tr><td class="label-cell">结束时间</td><td>${r.summary.endTime || '-'}</td></tr>
<tr><td class="label-cell">实际时长</td><td>${r.summary.durationActual != null ? r.summary.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">${m.totalRequests}</div><div class="metric-label">总请求数</div></div>
<div class="metric-item"><div class="metric-value">${m.successCount}</div><div class="metric-label">成功数</div></div>
<div class="metric-item"><div class="metric-value">${m.failCount}</div><div class="metric-label">失败数</div></div>
<div class="metric-item"><div class="metric-value">${(m.errorRate * 100).toFixed(2)}%</div><div class="metric-label">错误率</div></div>
<div class="metric-item"><div class="metric-value">${m.actualTps.toFixed(1)}</div><div class="metric-label">TPS</div></div>
<div class="metric-item"><div class="metric-value">${m.peakTps.toFixed(1)}</div><div class="metric-label">峰值 TPS</div></div>
<div class="metric-item"><div class="metric-value">${m.avgResponseTime.toFixed(1)}ms</div><div class="metric-label">平均响应时间</div></div>
<div class="metric-item"><div class="metric-value">${m.p95ResponseTime.toFixed(1)}ms</div><div class="metric-label">P95 响应时间</div></div>
<div class="metric-item"><div class="metric-value">${m.stdDev.toFixed(1)}ms</div><div class="metric-label">标准差</div></div>
<div class="metric-item"><div class="metric-value">${m.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>${m.p50ResponseTime.toFixed(1)}</td></tr>
<tr><td>P90</td><td>${m.p90ResponseTime.toFixed(1)}</td></tr>
<tr><td>P95</td><td>${m.p95ResponseTime.toFixed(1)}</td></tr>
<tr><td>P99</td><td>${m.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>${m.status2xx}</td><td>${((m.status2xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>3xx</td><td>${m.status3xx}</td><td>${((m.status3xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>4xx</td><td>${m.status4xx}</td><td>${((m.status4xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>5xx</td><td>${m.status5xx}</td><td>${((m.status5xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
</table>
</div>
${chartsHtml}
${snapshots.length > 0 ? `
<div class="section">
<h2>快照数据(${snapshots.length} 个采样点)</h2>
<table>
<tr><th>时间(s)</th><th>TPS</th><th>平均响应(ms)</th><th>P95(ms)</th><th>错误率</th></tr>
${snapshots.slice(0, 50).map(s => `
<tr>
<td>${s.elapsed.toFixed(0)}</td>
<td>${s.tps.toFixed(1)}</td>
<td>${s.avgResponseTime.toFixed(1)}</td>
<td>${s.p95ResponseTime.toFixed(1)}</td>
<td>${((s.failCount / ((s.successCount + s.failCount) || 1)) * 100).toFixed(1)}%</td>
</tr>`).join('')}
</table>
</div>` : ''}
`
}
// ==================== AI 分析 ====================
......@@ -1286,6 +1559,7 @@ defineExpose({ loadReport })
watch(() => route.query.taskId, (newId) => {
if (newId) {
selectedExecutionId.value = executionId.value
activeTab.value = (route.query.activeTab as string) || 'overview'
loadReport()
loadExecutionOptions()
}
......@@ -1320,6 +1594,10 @@ onUnmounted(() => {
padding: 0;
}
.report-tabs {
margin-bottom: 4px;
}
.empty-state {
display: flex;
flex-direction: column;
......
<!--
组件名称:RequestDetailPanel.vue
组件描述:性能测试请求明细察看结果树 - 筛选栏 / 分页表格 / 详情抽屉
@author czj
@date 2026-08-25
-->
<template>
<div class="request-detail-panel">
<!-- 未开启采集 且 确实无数据(避免后端 flag 误置时掩盖已采集数据) -->
<el-empty
v-if="enabled === 'off' && total === 0 && !loading"
description="请求详情采集未开启,请在任务配置中开启「请求详情采集」"
:image-size="80"
/>
<!-- 有数据时强制显示(后端 flag 误置时仍显示数据) -->
<template v-else-if="items.length > 0 || total > 0">
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select
v-model="filters.status"
placeholder="状态码"
clearable
size="small"
style="width: 110px;"
@change="onFilterChange"
>
<el-option label="全部" value="" />
<el-option label="2xx" value="200" />
<el-option label="4xx" value="4xx" />
<el-option label="5xx" value="5xx" />
<el-option label="网络异常" value="0" />
</el-select>
<el-select
v-model="filters.success"
placeholder="结果"
clearable
size="small"
style="width: 100px;"
@change="onFilterChange"
>
<el-option label="全部" value="" />
<el-option label="成功" :value="true" />
<el-option label="失败" :value="false" />
</el-select>
<el-input
v-model="filters.keyword"
placeholder="搜索 URL / 接口名"
clearable
size="small"
style="width: 200px;"
@clear="onFilterChange"
@keyup.enter="onFilterChange"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-input
v-model="filters.minResponseMs"
placeholder="最小耗时(ms)"
size="small"
style="width: 130px;"
@keyup.enter="onFilterChange"
/>
<span class="range-separator">~</span>
<el-input
v-model="filters.maxResponseMs"
placeholder="最大耗时(ms)"
size="small"
style="width: 130px;"
@keyup.enter="onFilterChange"
/>
<el-button size="small" type="primary" @click="onFilterChange">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button size="small" @click="resetFilters">
<el-icon><Refresh /></el-icon>重置
</el-button>
<el-tag v-if="truncated" type="warning" size="small" style="margin-left: 8px;">
仅显示前 {{ total }} 条(已截断)
</el-tag>
</div>
<!-- 加载中骨架 -->
<el-skeleton v-if="loading" :rows="6" animated />
<template v-else-if="items.length > 0">
<!-- 分页表格 -->
<el-table
:data="items"
stripe
border
size="small"
style="width: 100%"
@row-click="openDetail"
>
<el-table-column label="#" width="50" type="index" />
<el-table-column prop="api_name" label="接口名" min-width="120" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="70">
<template #default="{ row }">
<el-tag
:type="methodTagType(row.method)"
size="small"
effect="plain"
>{{ row.method }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="url" label="URL" min-width="240" show-overflow-tooltip />
<el-table-column label="状态码" width="80">
<template #default="{ row }">
<el-tag
:type="statusTagType(row.status)"
size="small"
>{{ row.status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="response_time_ms" label="耗时(ms)" width="90" sortable="custom" />
<el-table-column label="成功" width="60" align="center">
<template #default="{ row }">
<el-icon v-if="row.success" color="#67c23a" :size="16"><Check /></el-icon>
<el-icon v-else color="#f56c6c" :size="16"><Close /></el-icon>
</template>
</el-table-column>
<el-table-column label="断言" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.assert_result === true" type="success" size="small" effect="plain">通过</el-tag>
<el-tag v-else-if="row.assert_result === false" type="danger" size="small" effect="plain">失败</el-tag>
<span v-else style="color: #909399;">-</span>
</template>
</el-table-column>
<el-table-column label="错误类型" width="110" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.error_type" style="color: #f56c6c;">{{ row.error_type }}</span>
<span v-else style="color: #909399;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="openDetail(row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-if="total > pageSize"
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, total"
small
style="margin-top: 12px; justify-content: center;"
@current-change="loadData"
/>
</template>
<el-empty v-else description="暂无请求明细数据" :image-size="60" />
</template>
<!-- 兜底:非 off 状态 或 加载中(保证界面不空白) -->
<template v-else>
<el-skeleton v-if="loading" :rows="6" animated />
<el-empty v-else description="暂无请求明细数据" :image-size="60" />
</template>
<!-- 详情抽屉 -->
<el-drawer
v-model="drawerVisible"
title="请求详情"
:size="drawerSize"
direction="rtl"
destroy-on-close
>
<template v-if="detail">
<div class="drawer-body">
<!-- 基本信息 -->
<el-descriptions :column="2" border size="small" class="detail-section">
<el-descriptions-item label="方法">{{ detail.method }}</el-descriptions-item>
<el-descriptions-item label="状态码">
<el-tag :type="statusTagType(detail.status)" size="small">{{ detail.status || '-' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="耗时" :span="2">{{ detail.response_time_ms.toFixed(1) }} ms</el-descriptions-item>
<el-descriptions-item label="首字节延迟">{{ detail.latency_ms != null ? detail.latency_ms.toFixed(1) + ' ms' : '-' }}</el-descriptions-item>
<el-descriptions-item label="连接时间">{{ detail.connect_ms != null ? detail.connect_ms.toFixed(1) + ' ms' : '-' }}</el-descriptions-item>
<el-descriptions-item label="发送字节">{{ formatBytes(detail.sent_bytes) }}</el-descriptions-item>
<el-descriptions-item label="接收字节">{{ formatBytes(detail.received_bytes) }}</el-descriptions-item>
<el-descriptions-item label="断言结果" :span="2">
<el-tag v-if="detail.assert_result === true" type="success" size="small">通过</el-tag>
<el-tag v-else-if="detail.assert_result === false" type="danger" size="small">失败</el-tag>
<span v-else style="color: #909399;">未配置</span>
</el-descriptions-item>
<el-descriptions-item v-if="detail.error_message" label="错误信息" :span="2">
<span style="color: #f56c6c; word-break: break-all;">{{ detail.error_message }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 请求报文 -->
<div class="detail-section">
<div class="section-header">
<h4>请求报文 Request</h4>
<el-button size="small" text @click="copyText(detail.request_headers ? JSON.stringify(detail.request_headers, null, 2) : '')">
<el-icon><CopyDocument /></el-icon>复制请求头
</el-button>
</div>
<div class="detail-url">
<span class="label">URL:</span>
<el-tag :type="methodTagType(detail.method)" size="small" effect="dark">{{ detail.method }}</el-tag>
<code class="url-text">{{ detail.url }}</code>
</div>
<el-collapse>
<el-collapse-item title="请求头 Request Headers" name="reqHeaders">
<pre class="code-block">{{ formatJson(detail.request_headers) }}</pre>
</el-collapse-item>
<el-collapse-item v-if="detail.request_body != null" title="请求体 Request Body" name="reqBody">
<pre class="code-block">{{ formatJson(detail.request_body) }}</pre>
<el-button size="small" text @click="copyText(formatJson(detail.request_body))">
<el-icon><CopyDocument /></el-icon>复制请求体
</el-button>
</el-collapse-item>
</el-collapse>
</div>
<!-- 响应报文 -->
<div class="detail-section">
<div class="section-header">
<h4>响应报文 Response</h4>
<el-button size="small" text @click="copyText(detail.response_headers ? JSON.stringify(detail.response_headers, null, 2) : '')">
<el-icon><CopyDocument /></el-icon>复制响应头
</el-button>
</div>
<el-collapse>
<el-collapse-item title="响应头 Response Headers" name="resHeaders">
<pre class="code-block">{{ formatJson(detail.response_headers) }}</pre>
</el-collapse-item>
<el-collapse-item title="响应体 Response Body" name="resBody">
<pre class="code-block">{{ detail.response_body || '(空)' }}</pre>
<el-button v-if="detail.response_body" size="small" text @click="copyText(detail.response_body!)">
<el-icon><CopyDocument /></el-icon>复制响应体
</el-button>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
<el-skeleton v-else :rows="8" animated />
</el-drawer>
</div>
</template>
<script setup lang="ts">
/**
* 请求详情察看结果树子组件
*
* 功能:筛选分页表格 + 点击行展开详情抽屉,展示完整请求/响应报文
*/
import { ref, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
Search,
Refresh,
Check,
Close,
CopyDocument,
} from '@element-plus/icons-vue'
import { listRequestDetails, getRequestDetail } from '@/api/performance'
import type {
RequestDetailItem,
RequestDetailFull,
} from '@/types/performance'
const props = defineProps<{
executionId: string
taskId: string
}>()
// ==================== 状态 ====================
const loading = ref(false)
const enabled = ref('off')
const items = ref<RequestDetailItem[]>([])
const total = ref(0)
const truncated = ref(false)
const currentPage = ref(1)
const pageSize = 20
// 筛选条件
const filters = ref({
status: '',
success: '' as string | boolean,
keyword: '',
minResponseMs: '',
maxResponseMs: '',
})
// 详情抽屉
const drawerVisible = ref(false)
const detail = ref<RequestDetailFull | null>(null)
const drawerSize = ref('70%')
// ==================== 方法 ==================
/** 方法标签颜色 */
function methodTagType(method: string) {
const map: Record<string, string> = { GET: 'success', POST: 'primary', PUT: 'warning', DELETE: 'danger', PATCH: 'info' }
return map[method] || 'info'
}
/** 状态码标签颜色 */
function statusTagType(status: number) {
if (status >= 200 && status < 300) return 'success'
if (status >= 300 && status < 400) return 'info'
if (status >= 400 && status < 500) return 'warning'
if (status >= 500) return 'danger'
return 'info'
}
/** 格式化 JSON 显示 */
function formatJson(obj: any): string {
if (!obj) return '(空)'
try {
if (typeof obj === 'string') {
// 尝试解析为 JSON 再格式化
const parsed = JSON.parse(obj)
return JSON.stringify(parsed, null, 2)
}
return JSON.stringify(obj, null, 2)
} catch {
// 非 JSON 字符串,直接返回
return String(obj)
}
}
/** 字节数格式化 */
function formatBytes(bytes: number) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
/** 复制文本 */
function copyText(text: string) {
if (!text) {
ElMessage.info('无内容可复制')
return
}
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制到剪贴板')
}).catch(() => {
// 降级方案
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
ElMessage.success('已复制到剪贴板')
})
}
/** 重置筛选 */
function resetFilters() {
filters.value = { status: '', success: '', keyword: '', minResponseMs: '', maxResponseMs: '' }
currentPage.value = 1
loadData()
}
/** 筛选变更 */
function onFilterChange() {
currentPage.value = 1
loadData()
}
/** 加载数据 */
async function loadData(page = currentPage.value) {
if (!props.executionId) return
loading.value = true
try {
const params: Record<string, any> = {
page,
pageSize,
}
if (filters.value.status) params.status = filters.value.status
if (filters.value.success !== '') params.success = filters.value.success
if (filters.value.keyword) params.keyword = filters.value.keyword
if (filters.value.minResponseMs) params.min_response_ms = Number(filters.value.minResponseMs)
if (filters.value.maxResponseMs) params.max_response_ms = Number(filters.value.maxResponseMs)
const res = await listRequestDetails(props.executionId, params)
enabled.value = res.request_detail_enabled || 'off'
items.value = res.items || []
total.value = res.total || 0
truncated.value = res.truncated || false
currentPage.value = page
} catch (e: any) {
ElMessage.error('加载请求明细失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
loading.value = false
}
}
/** 打开详情抽屉 */
async function openDetail(row: RequestDetailItem) {
if (!props.executionId) return
drawerVisible.value = true
detail.value = null
try {
const res = await getRequestDetail(props.executionId, row.id)
detail.value = res
} catch (e: any) {
ElMessage.error('加载请求详情失败: ' + (e.response?.data?.detail || e.message || ''))
}
}
// ==================== 生命周期 ====================
watch(() => props.executionId, (newId) => {
if (newId) {
currentPage.value = 1
loadData()
}
})
onMounted(() => {
if (props.executionId) {
loadData()
}
})
</script>
<style scoped>
.request-detail-panel {
padding: 4px 0;
}
.filter-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.range-separator {
color: #909399;
font-size: 13px;
}
/* ========== 详情抽屉 ========== */
.drawer-body {
padding: 0 4px;
}
.detail-section {
margin-bottom: 20px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section-header h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #303133;
}
.detail-url {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 4px;
word-break: break-all;
}
.detail-url .label {
color: #909399;
font-size: 13px;
white-space: nowrap;
}
.detail-url .url-text {
font-size: 13px;
color: #409eff;
word-break: break-all;
flex: 1;
}
.code-block {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
</style>
\ No newline at end of file
......@@ -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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论