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

feat(performance): 预设配置登录凭据 + 增强指标体系

预设配置新增被测系统登录凭据可配置:
- 模型/Schema 新增 credential_name / login_username / login_password 字段
- 新建任务选择预设时自动填充登录凭据,任务显式传入优先
- 预设摘要展示凭据名称,菜单文案"接口预设"→"预设配置"

增强指标体系:
- 执行器采集 P95/标准差/Apdex/首字节延迟/TCP连接时间/字节数/峰值TPS/错误分类
- 模型/Schema/DB迁移补齐 performance_tasks 与 performance_snapshots 新列
- 监控面板与报告页展示新增指标
- 新增 test_performance_executor.py 单测
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 a6588716
# PRD — 预设配置与登录账号预设
> **版本**: v1.0
> **日期**: 2026-08-20
> **状态**: 待实现
> **关联 PRD**: `_PRD_接口预设功能.md`、`_PRD_性能测试模块需求文档.md`
---
## 1. 背景与目标
### 1.1 背景
当前性能测试模块存在以下问题:
1. **术语混淆**:当前菜单/页面称「接口预设」,但实际功能不仅是接口配置,还包括认证配置、捕获规则等,**「预设配置」** 更准确反映其作为「压测任务模板」的定位。
2. **登录凭据与接口配置脱节**:接口预设(`api_presets`)只存了 `account_key`(超级管理员/管理员/普通用户),但实际的用户名密码是硬编码在 `performance_service.py``_DEFAULT_ACCOUNTS` 中的。用户新建任务时仍需手动填写登录用户名/密码,或使用默认值,无法复用预设中保存的凭据。
3. **凭据无名称标识**:当前任务级的 `login_username`/`login_password` 字段没有名称标签,如果多个任务使用不同凭据,用户无法区分。
### 1.2 目标
1. **术语统一**:将「接口预设」更名为「预设配置」(菜单、页面、弹窗、API 路径注释等全部同步)
2. **预设存储登录凭据**:每个预设配置中直接存储 `credential_name`(凭据名称,如"超级管理员"、"测试账号A")、`login_username``login_password`
3. **新建任务一键填充**:选择预设配置后,自动填充接口字段(Method/URL/Headers/Body)+ 登录凭据(用户名/密码),用户无需再手动填写
4. **向后兼容**:已有预设自动获得默认凭据值;已有任务(无预设)不受影响
---
## 2. 功能范围
### 2.1 核心功能
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 术语重命名 | 接口预设 → 预设配置(菜单、页面标题、弹窗标题、API 注释) | P0 |
| 预设凭据字段 | 预设新增 `credential_name`/`login_username`/`login_password` | P0 |
| 预设编辑凭据 | 编辑预设弹窗增加凭据名称/用户名/密码输入框 | P0 |
| 任务表单自动填充 | 选择预设后自动填充登录凭据、摘要卡片显示凭据名称 | P0 |
| 向后兼容 | 旧预设自动补默认凭据;旧任务不受影响 | P0 |
### 2.2 预设配置包含的完整字段
| 字段 | 类型 | 说明 |
|------|------|------|
| **名称** | string | 预设配置名称,如"新建会议V3"(唯一) |
| **描述** | string | 可选,说明用途 |
| **HTTP 方法** | string | GET/POST/PUT/DELETE |
| **目标 URL** | string | 接口完整 URL(含协议和域名) |
| **请求头** | JSON | 自定义请求头 |
| **请求体** | JSON | 静态请求体(与 bodyTemplate 互斥) |
| **请求体模板** | JSON | 带 `{__XXX__}` 占位符的模板 |
| **响应捕获规则** | array | 捕获响应字段的规则列表 |
| **是否需要登录** | bool | 是否自动获取 Token |
| **是否需要签名** | bool | 是否生成 X-RANDOM/X-TIMESTAMP/X-SIGN |
| **使用账号 key** | string | 登录使用的账号 key(superadmin/admin/user) |
| **凭据名称** | string | **新增**,登录凭据的显示名称,如"超级管理员" |
| **登录用户名** | string | **新增**,自定义登录用户名,留空使用默认值 |
| **登录密码** | string | **新增**,自定义登录密码,留空使用默认值 |
---
## 3. 详细需求
### 3.1 术语重命名映射
| 当前位置 | 旧名称 | 新名称 |
|---------|--------|--------|
| 侧边栏菜单 | 接口预设 | 预设配置 |
| 路由 path | `presets` | 不变(URL 路径无需改,避免破坏书签) |
| 路由 meta.title | 接口预设 | 预设配置 |
| 列表页标题 | 接口预设列表 | 预设配置列表 |
| 新建弹窗标题 | 新建接口预设 | 新建预设配置 |
| 编辑弹窗标题 | 编辑接口预设 | 编辑预设配置 |
| 任务表单标签 | 接口预设 | 预设配置 |
| 提示文案 | 接口预设用于快速填充任务配置 | 预设配置用于快速填充任务配置 |
| 后端 API 路径 | 不变(`/api/performance/presets`) | 不变 |
| 后端模型/类名 | `ApiPreset` | 不变(类名不改,避免大面积重构) |
| 后端注释/docstring | 接口预设 | 预设配置 |
### 3.2 数据模型变更
#### 3.2.1 ApiPreset 模型新增字段
```python
class ApiPreset(Base):
"""预设配置数据库模型"""
__tablename__ = "api_presets"
# ... 现有字段不变 ...
# 新增:登录凭据配置
credential_name: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True, default=None, comment="凭据名称,如'超级管理员'"
)
login_username: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录用户名,留空使用默认"
)
login_password: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录密码,留空使用默认"
)
```
`to_dict()` 中同步返回这三个字段。
#### 3.2.2 数据库迁移
```python
# database.py _ensure_columns 追加:
("api_presets", "credential_name", "VARCHAR(100) DEFAULT NULL"),
("api_presets", "login_username", "VARCHAR(200) DEFAULT NULL"),
("api_presets", "login_password", "VARCHAR(200) DEFAULT NULL"),
```
### 3.3 Schema 变更
#### 3.3.1 ApiPresetCreate / ApiPresetUpdate 新增字段
```python
credential_name: Optional[str] = Field(None, description="凭据名称")
login_username: Optional[str] = Field(None, description="登录用户名")
login_password: Optional[str] = Field(None, description="登录密码")
```
#### 3.3.2 ApiPresetListItem 增加凭据名称
列表项中增加 `credential_name` 字段,用于任务表单下拉和列表展示。
### 3.4 前端变更
#### 3.4.1 侧边栏菜单 + 路由
- `App.vue``接口预设``预设配置`
- `router/index.ts``meta.title` 改为 `预设配置`
#### 3.4.2 预设配置列表页(ApiPresetList.vue)
- 页面标题/提示文案:「接口预设」→「预设配置」
- 列表列:新增「凭据名称」列(显示 credential_name,无则显示 `-`
- 新建/编辑弹窗标题:同步改名
#### 3.4.3 预设配置编辑弹窗(ApiPresetDialog.vue)
在「认证配置」区域(账号 select 下方)新增三个输入框:
```
┌─ 认证配置 ──────────────────────────────┐
│ 需要登录认证 [switch] │
│ 请求需要签名 [switch] │
│ 账号 [select: superadmin/admin/user] │
│ ── 登录凭据(可选,留空使用默认值) ── │
│ 凭据名称 [input] ← 如"超级管理员" │
│ 登录用户名 [input] │
│ 登录密码 [password input] │
└──────────────────────────────────────────┘
```
凭据名称、用户名、密码三个字段仅在 `authRequired=true` 时显示。
#### 3.4.4 任务创建表单(TaskList.vue)
**当前行为**:选择预设后只填充 Method/URL/Headers/Body/Auth/Sign/AccountKey,登录凭据仍需要手动填。
**改造后行为**:选择预设后,额外自动填充:
- `form.loginUsername` ← preset.login_username
- `form.loginPassword` ← preset.login_password
- 摘要卡片增加凭据名称行:`凭据:超级管理员`
**摘要卡片示例**
```
┌─ 预设配置: 新建会议V3 ───────────────────────┐
│ PUT https://192.168.5.44/meetingV3/api/message/book │
│ 需要登录(超级管理员) · 需要签名 │
└────────────────────────────────────────────────┘
```
**编辑回填**:如果任务有 `preset_id`,从预设加载凭据名称并显示。
### 3.5 后端处理逻辑
#### 3.5.1 创建预设时凭据字段处理
新建/编辑预设时,凭据名称/用户名/密码为可选字段,留空时存 `null`
#### 3.5.2 创建任务时预设继承(无需改动)
当前 `performance_service.py``create_task()` 中,预设继承已处理 `account_key`
```python
data["account_key"] = data.get("account_key") or preset.account_key
```
新增的 `login_username`/`login_password` 同理也会被任务数据覆盖或继承。但需要注意:**当前逻辑中 `login_username`/`login_password` 不是从预设继承的**,需要补充:
```python
# 从预设继承登录凭据
if not data.get("login_username"):
data["login_username"] = getattr(preset, "login_username", None)
if not data.get("login_password"):
data["login_password"] = getattr(preset, "login_password", None)
```
#### 3.5.3 执行时凭据使用(无需改动)
`_build_client_config()` 已支持从 `task_dict` 读取 `login_username`/`login_password`,预设继承后自动生效,无需额外改动。
---
## 4. 非功能需求
### 4.1 兼容性
- 已有预设(无 `credential_name`/`login_username`/`login_password` 字段)完全不受影响,默认值为 `null`
- 已有任务(无 `preset_id`)不受影响,创建/编辑/执行逻辑不变
- 预设凭据更新后,已创建的任务不受影响(任务存储的是当时的值,不是引用)
### 4.2 约束
- 凭据名称仅用作显示标签,不参与唯一性校验(不同预设可用相同凭据名称)
- 登录密码以明文存库(`api_presets.login_password`),内网平台可接受
- 预设凭据字段留空时,执行时回退 `_DEFAULT_ACCOUNTS` 默认值
---
## 5. 应用场景示例
### 5.1 场景一:创建含凭据的预设配置
1. 运维在预设配置页面新建「新建会议V3」:
- 方法:PUT,URL:`https://192.168.5.44/meetingV3/api/message/book`
- 需要登录 + 需要签名
- 凭据名称:`超级管理员`,用户名:`admin@xty`,密码:`Ubains@13579`
2. 测试同学创建压测任务:
- 选择预设:「新建会议V3」→ 自动填充所有接口字段 + 登录凭据
- 摘要卡片显示:`需要登录(超级管理员)`
- 设置并发数:10,时长:60s → 执行成功
### 5.2 场景二:使用默认凭据的预设
1. 运维新建预设「查询接口」:
- 方法:GET,URL:`https://192.168.5.44/platform/api/xxx`
- 需要登录,但凭据名称/用户名/密码留空
2. 测试同学创建任务 → 选择该预设 → 登录凭据为空 → 执行时使用默认账号(`admin@xty / Ubains@13579`
---
## 6. 影响范围
### 6.1 修改文件
| 文件 | 变更 |
|------|------|
| `backend/app/models/api_preset.py` | 新增 `credential_name`/`login_username`/`login_password` 字段 + `to_dict()` 返回 |
| `backend/app/schemas/api_preset.py` | Create/Update/Response/ListItem 新增三个字段 |
| `backend/app/database.py` | `_ensure_columns` 追加 3 条 ALTER TABLE |
| `backend/app/services/performance_service.py` | `create_task()` 预设继承增加 login_username/login_password |
| `frontend/src/App.vue` | 侧边栏菜单文字「接口预设」→「预设配置」 |
| `frontend/src/router/index.ts` | meta.title「接口预设」→「预设配置」 |
| `frontend/src/views/performance/ApiPresetList.vue` | 页面文案 + 新增「凭据名称」列 |
| `frontend/src/views/performance/ApiPresetDialog.vue` | 编辑弹窗新增凭据名称/用户名/密码输入框 |
| `frontend/src/views/performance/TaskList.vue` | 预设摘要卡片显示凭据名称;onPresetChange 填充登录凭据 |
### 6.2 无需改动
| 文件 | 原因 |
|------|------|
| `backend/app/routers/api_preset.py` | CRUD 路由无字段级逻辑,schema 变更自动生效 |
| `backend/app/services/api_preset_service.py` | 业务逻辑不涉及凭据字段特殊处理 |
| `backend/app/executors/performance_executor.py` | 凭据从 service 注入,透明传递 |
| `backend/app/routers/performance.py` | 路由层无变更 |
| `frontend/src/api/performance.ts` | API 封装无变更 |
| `frontend/src/types/performance.ts` | 类型定义跟随 schema 自动更新(需确认字段 map) |
---
## 7. 验收标准
| # | 验收项 | 验证方式 |
|---|--------|---------|
| 1 | 侧边栏菜单显示「预设配置」 | 打开页面,左侧菜单文字正确 |
| 2 | 预设配置列表页文案正确 | 标题、提示文案均为「预设配置」 |
| 3 | 新建预设可填写凭据名称/用户名/密码 | 编辑弹窗展示三个输入框 |
| 4 | 编辑预设可修改凭据字段 | 保存后重新打开,内容正确 |
| 5 | 旧预设打开编辑弹窗,凭据字段为空 | 兼容旧数据,无异常 |
| 6 | 新建任务选择预设后自动填充登录凭据 | 表单 loginUsername/loginPassword 自动填入 |
| 7 | 摘要卡片显示凭据名称 | 卡片显示「需要登录(超级管理员)」 |
| 8 | 预设凭据留空时,任务执行使用默认账号 | 执行成功,日志确认默认凭据 |
| 9 | 预设凭据变化不影响已有任务 | 历史任务执行时仍用原值 |
---
*本文档为预设配置与登录账号预设功能的需求定义,后续将输出计划执行文档用于指导开发。*
\ No newline at end of file
# 执行计划 — 预设配置与登录账号预设
> **生成时间**: 2026-08-20
> **基于 PRD**: `_PRD_预设配置与登录账号预设.md`
> **当前分支**: `platform-auto-test`
> **预计工作量**: 8 个文件改动(后端 4 + 前端 4)+ 前端构建验证
---
## 概述
将「接口预设」更名为「预设配置」,并在预设中新增登录凭据(凭据名称/用户名/密码),新建任务选择预设时自动填充登录凭据,用户无需手动输入。
### 核心改动一览
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/models/api_preset.py` | 修改 | 新增 `credential_name`/`login_username`/`login_password` 字段 + `to_dict()` 返回 |
| `backend/app/schemas/api_preset.py` | 修改 | Create/Update/Response/ListItem 新增三个字段 |
| `backend/app/database.py` | 修改 | `_ensure_columns` 追加 3 条 ALTER TABLE |
| `backend/app/services/performance_service.py` | 修改 | `create_task()` 预设继承补充 login_username/login_password |
| `frontend/src/App.vue` | 修改 | 侧边栏菜单文字「接口预设」→「预设配置」 |
| `frontend/src/router/index.ts` | 修改 | meta.title「接口预设」→「预设配置」 |
| `frontend/src/views/performance/ApiPresetList.vue` | 修改 | 页面文案 + 新增「凭据名称」列 |
| `frontend/src/views/performance/ApiPresetDialog.vue` | 修改 | 编辑弹窗新增凭据名称/用户名/密码输入框 |
| `frontend/src/views/performance/TaskList.vue` | 修改 | 预设摘要 + onPresetChange 填充登录凭据 |
---
## Phase 1: 后端模型 + Schema(2 文件)
### 1.1 models/api_preset.py
`ApiPreset` 类中新增三个字段(放在 `account_key` 字段之后、`capture_rules` 之前):
```python
# 登录凭据配置
credential_name: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True, default=None, comment="凭据名称,如'超级管理员'"
)
login_username: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录用户名,留空使用默认"
)
login_password: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录密码,留空使用默认"
)
```
`to_dict()` 方法中新增返回:
```python
"credential_name": self.credential_name,
"login_username": self.login_username,
"login_password": self.login_password,
```
### 1.2 schemas/api_preset.py
**ApiPresetCreate** — 新增三个可选字段:
```python
credential_name: Optional[str] = Field(None, description="凭据名称,如'超级管理员'")
login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认")
login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认")
```
**ApiPresetUpdate** — 同上:
```python
credential_name: Optional[str] = Field(None, description="凭据名称")
login_username: Optional[str] = Field(None, description="自定义登录用户名")
login_password: Optional[str] = Field(None, description="自定义登录密码")
```
**ApiPresetResponse** — 新增返回:
```python
credential_name: Optional[str] = None
login_username: Optional[str] = None
login_password: Optional[str] = None
```
**ApiPresetListItem** — 列表项仅需返回凭据名称(用于下拉和列表展示):
```python
credential_name: Optional[str] = None
```
### 1.3 数据库迁移
`database.py``_ensure_columns()` 方法中追加:
```python
# 预设配置:登录凭据名称/用户名/密码(旧库升级)
("api_presets", "credential_name", "VARCHAR(100) DEFAULT NULL"),
("api_presets", "login_username", "VARCHAR(200) DEFAULT NULL"),
("api_presets", "login_password", "VARCHAR(200) DEFAULT NULL"),
```
---
## Phase 2: 后端 Service 预设继承补充(1 文件)
### 2.1 services/performance_service.py
`create_task()` 方法中,预设继承块(`if data.get("preset_id")`)内,补充 login_username/login_password 的继承逻辑。
**当前代码**(约第 190-240 行):
```python
if data.get("preset_id"):
preset = await self.db.get(ApiPreset, data["preset_id"])
if not preset:
raise HTTPException(status_code=404, detail="预设不存在")
# 预设字段作为默认值,任务显式传入的字段覆盖
if not data.get("method"):
data["method"] = preset.method
if not data.get("target_url"):
data["target_url"] = preset.target_url
# ... 其他字段 ...
if not data.get("account_key"):
data["account_key"] = preset.account_key
# ⬇️ 新增:从预设继承登录凭据
if not data.get("login_username"):
data["login_username"] = preset.login_username
if not data.get("login_password"):
data["login_password"] = preset.login_password
```
**注意**`login_username`/`login_password``PerformanceTask` 模型中已存在(第 111-114 行),无需再改模型。
---
## Phase 3: 前端文案更新 + 菜单/路由(2 文件)
### 3.1 App.vue
搜索 `接口预设` 字符串,改为 `预设配置`
```html
<!-- 第 90 行附近 -->
<el-menu-item index="/performance/presets">预设配置</el-menu-item>
```
侧边栏面板的 `presets` 映射也要改:
```typescript
// 第 286 行附近
presets: '预设配置',
```
### 3.2 router/index.ts
```typescript
// 第 188 行附近
meta: { title: '预设配置' }
```
---
## Phase 4: 前端预设配置列表页(1 文件)
### 4.1 ApiPresetList.vue
**文案变更**
- 提示文案:`接口预设用于快速填充任务配置``预设配置用于快速填充任务配置`
**新增列**:在「方法」列之后、「目标 URL」列之前插入凭据名称列:
```html
<el-table-column prop="credentialName" label="凭据名称" width="150" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.credentialName || '-' }}</span>
</template>
</el-table-column>
```
---
## Phase 5: 前端预设编辑弹窗(1 文件)
### 5.1 ApiPresetDialog.vue
**弹窗标题**`新建接口预设``新建预设配置``编辑接口预设``编辑预设配置`
**新增表单字段**:在「账号」select 下方增加凭据配置区域(仅 `authRequired=true` 时显示):
```html
<!-- 认证配置区域末尾,accountKey 之后 -->
<el-divider v-if="form.authRequired" style="margin: 8px 0" />
<template v-if="form.authRequired">
<div class="credential-section-label">登录凭据(可选,留空使用默认值)</div>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="凭据名称">
<el-input v-model="form.credentialName" placeholder="如:超级管理员" clearable />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="登录用户名">
<el-input v-model="form.loginUsername" placeholder="留空使用默认" clearable />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="登录密码">
<el-input v-model="form.loginPassword" type="password" show-password placeholder="留空使用默认" clearable />
</el-form-item>
</el-col>
</el-row>
</template>
```
**表单数据模型 (PresetForm)** 新增字段:
```typescript
interface PresetForm {
// ... 现有字段 ...
credentialName: string
loginUsername: string
loginPassword: string
}
```
**defaultForm()** 新增默认值:
```typescript
const defaultForm = (): PresetForm => ({
// ... 现有 ...
credentialName: '',
loginUsername: '',
loginPassword: '',
})
```
**initForm()** — 编辑时回填:
```typescript
form.credentialName = p.credentialName || ''
form.loginUsername = p.loginUsername || ''
form.loginPassword = p.loginPassword || ''
```
**handleSave()** — 提交数据补发:
```typescript
const data: any = {
// ... 现有 ...
credentialName: form.credentialName || null,
loginUsername: form.loginUsername || null,
loginPassword: form.loginPassword || null,
}
```
---
## Phase 6: 前端任务列表预设填充(1 文件)
### 6.1 TaskList.vue
**onPresetChange()** — 选择预设后填充登录凭据:
`onPresetChange` 函数的赋值段末尾(`form.accountKey` 之后)补充:
```typescript
// 填充登录凭据
form.loginUsername = preset.loginUsername || ''
form.loginPassword = preset.loginPassword || ''
```
**预设摘要卡片** — 显示凭据名称:
在认证行中,当前显示 `需要登录(超级管理员)` 的逻辑已通过 `accountKeyLabel(preset.accountKey)` 显示。需改为显示预设的 `credentialName`
```html
<div>
<strong>认证:</strong>
{{ selectedPreset.authRequired ? '需要登录' : '无需登录' }}
<template v-if="selectedPreset.authRequired">
({{ selectedPreset.credentialName || accountKeyLabel(selectedPreset.accountKey) }})
</template>
· 签名:{{ selectedPreset.signRequest ? '需要' : '不需要' }}
</div>
```
**注意**:优先显示 `credentialName`,回退显示 `accountKey` 对应的中文标签,保持向后兼容。
---
## 验证清单
### 后端验证
| # | 验证项 | 方式 |
|---|--------|------|
| 1 | 模型新增字段编译通过 | `py_compile` 检查 models/api_preset.py |
| 2 | Schema 新增字段编译通过 | `py_compile` 检查 schemas/api_preset.py |
| 3 | 旧库启动自动加列 | 启动后查表 `api_presets` 确认列存在 |
| 4 | POST /api/performance/presets 带凭据字段 | 201 创建成功,返回含 credential_name/login_username |
| 5 | POST /api/performance/presets 不带凭据字段 | 201 创建成功,字段为 null |
| 6 | POST /api/performance/tasks 带 preset_id 含凭据 | 任务创建,login_username/login_password 从预设继承 |
| 7 | POST /api/performance/tasks 带 preset_id 但传了 login_username | 任务显式值覆盖预设值 |
### 前端验证
| # | 验证项 | 方式 |
|---|--------|------|
| 8 | 侧边栏菜单显示「预设配置」 | 打开页面确认 |
| 9 | 预设配置列表页文案正确 | 标题、提示文案均为「预设配置」 |
| 10 | 列表页显示凭据名称列 | 有预设→显示凭据名;无预设→显示 `-` |
| 11 | 新建预设弹窗有凭据三个输入框 | 打开弹窗,确认输入框显示 |
| 12 | 编辑预设弹窗回填凭据 | 保存后重新打开,数据正确 |
| 13 | 新建任务选择预设 → 自动填充登录凭据 | 表单 loginUsername/loginPassword 自动填入 |
| 14 | 摘要卡片显示凭据名称 | 卡片显示「需要登录(超级管理员)」 |
| 15 | 预设凭据为空 → 摘要显示 account_key 标签 | 回退显示「超级管理员(admin)」 |
| 16 | 前端构建通过 | `npm run build` 无 TS 错误 |
---
## 执行顺序
```
Step 1: models/api_preset.py 新增字段 + to_dict()
Step 2: schemas/api_preset.py 新增字段
Step 3: database.py 追加迁移
Step 4: services/performance_service.py 预设继承补充
Step 5: App.vue + router/index.ts 菜单文案
Step 6: ApiPresetList.vue 文案 + 列
Step 7: ApiPresetDialog.vue 弹窗 + 凭据输入框
Step 8: TaskList.vue 预设填充 + 摘要
Step 9: npm run build 验证
```
---
*本文档由 Claude Code 生成,基于 PRD 文档 `_PRD_预设配置与登录账号预设.md`。*
\ No newline at end of file
# HANDOFF_性能测试 # HANDOFF_性能测试
> 生成时间:2026-08-12 15:00 > 生成时间:2026-08-12 15:00
> 最后更新:2026-08-19 18:05 > 最后更新:2026-08-20 18:00
> 当前分支:`platform-auto-test` > 当前分支:`platform-auto-test`
> 最近提交:`69c215fe feat(device-sim): 无纸化授权码 + 环境配置弹窗简化 + 批量删除性能优化` > 最近提交:`a6588716 feat(device-sim): 无纸化模拟器Token获取 + 消息定时拉取`
> 主分支:`master` > 主分支:`master`
--- ---
...@@ -21,13 +21,80 @@ ...@@ -21,13 +21,80 @@
| **读取系统配置被测系统URL** | ✅ 完成 | 运行时读取系统配置,不再依赖任务 target_url(2026-08-19) | | **读取系统配置被测系统URL** | ✅ 完成 | 运行时读取系统配置,不再依赖任务 target_url(2026-08-19) |
| **任务级自定义登录凭据** | ✅ 完成 | 新建/编辑任务可填自定义用户名/密码,验证码仍固定 csba(2026-08-19) | | **任务级自定义登录凭据** | ✅ 完成 | 新建/编辑任务可填自定义用户名/密码,验证码仍固定 csba(2026-08-19) |
| **从 curl 自动生成压测任务** | ✅ 完成 | 新建弹窗「从 curl 导入」→ 解析回填表单(2026-08-19) | | **从 curl 自动生成压测任务** | ✅ 完成 | 新建弹窗「从 curl 导入」→ 解析回填表单(2026-08-19) |
| **指标体系增强(10项新指标)** | ✅ 完成 | 后端 P95/标准差/Apdex/延迟/连接时间/字节数/峰值TPS/错误分类 + 前端报告面板展示(2026-08-20) |
--- ---
## 🆕 最新会话进度(2026-08-19 18:05 ## 🆕 最新会话进度(2026-08-20 18:00
### 本次会话完成的核心工作 ### 本次会话完成的核心工作
#### 0. 指标体系增强(10项新指标)✅(已完成,未部署)
**用户需求**:对标 JMeter 的市场标准,增强性能测试指标体系,提供更全面的分析维度。
**参考标准**:JMeter 默认指标 + 行业主流(P95/标准差/Apdex/吞吐量/错误分类等)
**本次新增 10 项指标**
| # | 指标 | 含义 | 采集方式 |
|---|------|------|---------|
| 1 | **P95** | 95% 请求响应时间阈值 | 线性插值百分位数 |
| 2 | **标准差 (Std Dev)** | 响应时间离散程度 | 总体标准差 `sqrt(sum((x-mean)²)/n)` |
| 3 | **Apdex** | 用户满意度指数 | `(满意+容忍/2)/总数`,T=500ms,4T=2000ms |
| 4 | **首字节延迟 (Latency/TTFB)** | 从发送到收到第一个字节 | 请求级记录,平均/最大 |
| 5 | **连接时间 (Connect Time)** | TCP 连接建立耗时 | 请求级记录,平均/最大 |
| 6 | **发送/接收速率 (KB/s)** | 网络吞吐量 | 周期统计 `bytes / elapsed_sec` |
| 7 | **峰值 TPS** | 最高每秒吞吐量 | 周期快照中取最大值 |
| 8 | **错误分类(6种)** | timeout/connect_error/client_error/http_4xx/http_5xx/assertion_fail | record() 时按 error_type/status/error 分类 |
| 9 | **实时并发用户数** | 当前活跃线程数 | 外部传入,快照中记录 |
| 10 | **P50 响应时间** | 中位数响应时间 | 线性插值百分位数 |
**后端变更(3 文件)**
| 文件 | 变更 |
|------|------|
| `backend/app/executors/performance_executor.py` | **MetricsCollector 增强** — 新增 `_std_dev()` / `_apdex()` / `_percentile()` 静态方法;`record()` 新增 `latency_ms/connect_ms/sent_bytes/received_bytes` 参数 + 错误分类逻辑;`snapshot()` 新增 `std_dev/p95/latency_avg/connect_time_avg/sent_bytes/received_bytes/peak_tps/concurrent_users` 输出;`summary()` 新增 `p50/p95/std_dev/apdex/latency_avg/connect_time_avg/total_sent/received_bytes/sent_kb_per_sec/received_kb_per_sec/error_type_*` 输出;`_reset()` 修复缺少 `_peak_tps` 初始化导致的 AttributeError |
| `backend/app/services/performance_service.py` | `_run_sync()``record()` 调用补传 `latency_ms/connect_ms/sent_bytes/received_bytes``summary()` 结果写入 DB 的 `p50/p95/std_dev/apdex/latency_avg/connect_time_avg/sent_kb_per_sec/received_kb_per_sec/error_type_*` 字段 |
| `backend/app/database.py` | 迁移 `performance_task_results` 表新增 10 个指标列 + 3 个错误分类列(`_ensure_columns` 追加 `avg_latency/avg_connect_time/std_dev/apdex/p95/p50/sent_kb_per_sec/received_kb_per_sec/error_type_connect_error/error_type_client_error/error_type_assertion`) |
**后端模型/schema 变更(2 文件)**
| 文件 | 变更 |
|------|------|
| `backend/app/models/performance.py` | `PerformanceTaskResult` 新增 10 个指标字段 + 3 个错误分类字段(`avg_latency/max_latency/avg_connect_time/max_connect_time/std_dev/apdex/p95/p50/sent_kb_per_sec/received_kb_per_sec/error_type_connect_error/error_type_client_error/error_type_assertion`) |
| `backend/app/schemas/performance.py` | `PerformanceReportMetrics` 补全所有新指标字段;`_ensure_columns` 迁移逻辑同步 |
**前端变更(3 文件)**
| 文件 | 变更 |
|------|------|
| `frontend/src/types/performance.ts` | `PerformanceReportMetrics` 新增 `stdDev/apdex/latencyAvg/latencyMin/latencyMax/connectTimeAvg/connectTimeMax/sentKbPerSec/receivedKbPerSec/errorTypeConnectError/errorTypeClientError/errorTypeAssertion``PerformanceSnapshot` 新增 `stdDev/latencyAvg/connectTimeAvg/sentBytes/receivedBytes/peakTps``PerfWsSnapshot.data` 新增 `stdDev/latencyAvg/connectTimeAvg/sentBytes/receivedBytes/peakTps``ApiPreset`/`ApiPresetCreate` 新增 `credentialName/loginUsername/loginPassword` |
| `frontend/src/views/performance/MonitorPanel.vue` | 实时监控面板新增 3 张 ECharts 图表:**响应时间趋势(含 P95)**(4 条线 → 5 条线,新增 P95 橙色线)、**延迟分解趋势**(首字节延迟 + 连接时间,虚线区分)、**带宽趋势**(发送/接收 KB/s),新增 `latencyChartRef`/`bandwidthChartRef` 引用和 dispose 逻辑 |
| `frontend/src/views/performance/ReportPanel.vue` | 报告面板核心指标从 8 个扩展到 **16 个卡片**(4 行):Row1 总请求数/成功数/失败数/错误率;Row2 TPS/峰值 TPS/平均响应时间/**P95**(颜色编码 <300ms 绿 <1000ms 黄 ≥1000ms 红);Row3 **标准差**/**Apdex**(颜色编码 ≥0.94 绿 ≥0.85 黄 <0.85 红)/**平均首字节延迟**/**平均连接时间**;Row4 **发送速率 KB/s**/**接收速率 KB/s**/最小/最大响应时间。新增**分位数表**(P95 行)、**错误分类表**(6 种错误类型+数量+占比)、**网络传输统计表****延迟分解图**(首字节延迟+连接时间)、**带宽趋势图**(发送/接收 KB/s) |
**测试文件(1 新建,25 个测试)**
| 文件 | 测试 |
|------|------|
| `backend/tests/test_performance_executor.py` | **25 passed**`TestStdDev`(3): 空/单值/常数/已知值;`TestApdex`(5): 空/全满意/全容忍/混合/自定义阈值;`TestPercentile`(4): 单值/空/线性插值/未排序输入;`TestRecord`(7): 成功记录/4xx/5xx/断言失败/timeout/连接错误;`TestSnapshot`(3): 空快照/字段重置/多周期;`TestSummary`(3): 空汇总/聚合/错误类型/速率 |
**验证结果**
- 后端单元测试:`pytest tests/test_performance_executor.py -v`**25 passed in 0.83s**
- 后端全量测试:`pytest tests/ -v` → 252 passed / 4 failed / 8 errors(4 failed 均为 pre-existing `execution_cancel` 测试,8 errors 为 pre-existing API 测试错误,非本次引入)✅
- 前端 `npm run build`**built in 30.21s**,vue-tsc 类型检查无错误 ✅(修复了 ApiPreset 类型缺少 `credentialName/loginUsername/loginPassword` 的 TS 错误)
- **尚未部署 5.60 服务器**
**⚠️ 关键算法**
- **P95 线性插值**`sorted_data[f] + (sorted_data[c] - sorted_data[f]) * (k - f)`,其中 `k = p/100 * (n-1)``f=floor(k)``c=ceil(k)`
- **总体标准差**`math.sqrt(sum((x-mean)²)/n)`(分母为 n,非 n-1)
- **Apdex**:T=500ms 固定,满意 ≤T,容忍 ≤4T,超出 >4T;公式 `(satisfied + tolerating/2) / total`
- **错误分类优先级**`error_type` 字段 > HTTP 状态码 → 错误消息中的关键字
---
### 上一会话(2026-08-19)完成的核心工作
#### 0. 从 curl 自动生成压测任务 ✅(已在 5.60 部署) #### 0. 从 curl 自动生成压测任务 ✅(已在 5.60 部署)
**用户需求**:新建性能测试任务时,粘贴接口调用 curl(含 `-X`/URL/`-H`/`-d`)+ 补充账号密码/压测模式/并发数/时长/预热 → 自动回填表单生成压测任务,减少手动填写。 **用户需求**:新建性能测试任务时,粘贴接口调用 curl(含 `-X`/URL/`-H`/`-d`)+ 补充账号密码/压测模式/并发数/时长/预热 → 自动回填表单生成压测任务,减少手动填写。
...@@ -342,8 +409,8 @@ python3 /tmp/test_tr.py # 输出 NOW: 2026-08-17 12:59:09(带秒)✓ ...@@ -342,8 +409,8 @@ python3 /tmp/test_tr.py # 输出 NOW: 2026-08-17 12:59:09(带秒)✓
- 本地验证脚本:`backend/meeting_test22.py` ~ `meeting_test25.py`(探测 API 最小必需字段/时段/时长限制,可复用) - 本地验证脚本:`backend/meeting_test22.py` ~ `meeting_test25.py`(探测 API 最小必需字段/时段/时长限制,可复用)
- 本地任务 `perf_f27debe671d84b4fb3908af5beac67ed`(同名任务)尚未做完整验证(服务器版已验证通过) - 本地任务 `perf_f27debe671d84b4fb3908af5beac67ed`(同名任务)尚未做完整验证(服务器版已验证通过)
**后端代码行数**:2173 行(executor 748 + service 588 + router 296 + model 279 + schema 262 **后端代码行数**:3037 行(executor 1138 + service 854 + router 329 + model 337 + schema 379
**前端代码行数**:1768 行(index 98 + TaskList 476 + MonitorPanel 459 + ReportPanel 373 + api 121 + types 241) **前端代码行数**:2746 行(index 45 + TaskList 917 + MonitorPanel 617 + ReportPanel 626 + api 160 + types 381)
--- ---
...@@ -630,40 +697,43 @@ cd backend && uvicorn app.main:app --reload --port 8002 ...@@ -630,40 +697,43 @@ cd backend && uvicorn app.main:app --reload --port 8002
## 当前运行状态 ## 当前运行状态
### 生产服务器 192.168.5.60(2026-08-17 13:45 ### 生产服务器 192.168.5.60(2026-08-20
- 容器 `plat-auto-test-app`:healthy,内部端口 80(宿主机 80) - 容器 `plat-auto-test-app`:healthy,内部端口 80(宿主机 80)
- 部署内容:修复版 `template_resolver.py` + 含 `_save_outputs` 的 `performance_service.py` + 含捕获的 `performance_executor.py`(DBG 调试日志已清除,grep 验证 0 残留 - **当前部署版本**:含 curl 导入 + 自定义凭据 + 读取系统配置 URL(2026-08-19 部署
- 字节码缓存已清理,容器已重启加载新代码 - **待部署**:指标体系增强(10 项新指标)— 后端 5 文件 + 前端 dist,见上文部署清单
- 任务 `perf_5a1a3b4e00e04b2f96713f4108580d2b` 验证通过(169 请求 / 0 失败 / TPS 10.96 / message_ids 捕获 2 个) - 任务 `perf_5a1a3b4e00e04b2f96713f4108580d2b` 验证通过(169 请求 / 0 失败 / TPS 10.96 / message_ids 捕获 2 个)
- MySQL 表 `performance_task_outputs` 已存在且有数据 - MySQL 表 `performance_task_outputs` 已存在且有数据
### 本地(Windows 开发机) ### 本地(Windows 开发机)
- 后端:`python -m uvicorn app.main:app --port 8001`(无 --reload,日志 `/tmp/backend_8001.log` - 后端:`python -m uvicorn app.main:app --port 8001`(当前无 --reload 运行中
- 前端:`http://localhost:3000` - 前端:`http://localhost:3000`
- 本地数据库 `data/test_platform.db` 含同名任务 `perf_f27debe671d84b4fb3908af5beac67ed`(待完整回归) - 本地数据库 `data/test_platform.db` 含历史任务
- **最新改动**:指标体系增强(10 项新指标,25 个单元测试全部通过 ✅)
- **前端构建**:`npm run build` 30.21s 通过 ✅
--- ---
## 未提交的代码 ## 未提交的代码
性能测试模块代码尚未 commit。影响文件: 性能测试模块代码尚未 commit。影响文件(含本次会话新增)
``` ```
# 后端新文件 # 后端新文件
backend/app/executors/performance_executor.py (NEW) backend/app/executors/performance_executor.py (NEW, 1138行)
backend/app/services/performance_service.py (NEW) backend/app/services/performance_service.py (NEW, 854行)
backend/app/routers/performance.py (NEW) backend/app/routers/performance.py (NEW, 329行)
backend/app/models/performance.py (NEW) backend/app/models/performance.py (NEW, 337行)
backend/app/schemas/performance.py (NEW) backend/app/schemas/performance.py (NEW, 379行)
backend/app/main.py (MODIFIED: 注册 performance router) backend/app/main.py (MODIFIED: 注册 performance router)
backend/tests/test_performance_executor.py (NEW, 247行, 25测试)
# 前端新文件 # 前端新文件
frontend/src/views/performance/index.vue (NEW) frontend/src/views/performance/index.vue (NEW, 45行)
frontend/src/views/performance/TaskList.vue (NEW) frontend/src/views/performance/TaskList.vue (NEW, 917行)
frontend/src/views/performance/MonitorPanel.vue (NEW) frontend/src/views/performance/MonitorPanel.vue (NEW, 617行)
frontend/src/views/performance/ReportPanel.vue (NEW) frontend/src/views/performance/ReportPanel.vue (NEW, 626行)
frontend/src/api/performance.ts (NEW) frontend/src/api/performance.ts (NEW, 160行)
frontend/src/types/performance.ts (NEW) frontend/src/types/performance.ts (NEW, 381行)
frontend/src/router/index.ts (MODIFIED: 添加 /performance 路由) frontend/src/router/index.ts (MODIFIED: 添加 /performance 路由)
frontend/src/App.vue (MODIFIED: 添加侧边栏菜单项) frontend/src/App.vue (MODIFIED: 添加侧边栏菜单项)
``` ```
...@@ -672,7 +742,23 @@ frontend/src/App.vue (MODIFIED: 添加侧边栏 ...@@ -672,7 +742,23 @@ frontend/src/App.vue (MODIFIED: 添加侧边栏
## 下一步任务 ## 下一步任务
### Phase 5 - 集成测试(✅ 核心已通过,2026-08-17) ### 指标体系增强部署(待部署 5.60)
本次会话完成的指标体系增强(10 项新指标)尚未部署到 5.60 服务器。部署清单:
- 后端文件(5 个):`executors/performance_executor.py``services/performance_service.py``models/performance.py``schemas/performance.py``database.py`
- 前端 dist 全量(已构建通过)
- 部署步骤:scp 后端文件 → 清理 *.pyc → `docker restart plat-auto-test-app` → scp 前端 dist → 浏览器 Ctrl+F5 强刷
- 部署后验证:`GET /api/performance/tasks/{旧任务ID}/report` 返回新指标字段(p95/std_dev/apdex 等)
### 集成验证(待完成)
- 运行一次短压测(concurrency=1, duration=5s),验证:
- 报告 API 返回所有新指标字段 ✅
- WebSocket 快照含新字段(std_dev/latency_avg/connect_time_avg/sentBytes/receivedBytes/peakTps)
- 前端报告面板显示 16 个指标卡片 + 新图表
- 错误分类(可手动构造 4xx/5xx 场景验证)
### P0 - 按请求序号错开时间段(并发压测预约类接口的关键)
以下环节已在 5.60 服务器 + 真实会议预约 API 上验证通过: 以下环节已在 5.60 服务器 + 真实会议预约 API 上验证通过:
- 创建/更新任务(PUT 更新 concurrency/duration/body)✅ - 创建/更新任务(PUT 更新 concurrency/duration/body)✅
......
...@@ -161,6 +161,36 @@ async def _ensure_columns(conn) -> None: ...@@ -161,6 +161,36 @@ async def _ensure_columns(conn) -> None:
("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"), ("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 定时任务:安全测试配置ID(旧库升级) # 定时任务:安全测试配置ID(旧库升级)
("scheduled_tasks", "config_id", "VARCHAR(64) DEFAULT NULL"), ("scheduled_tasks", "config_id", "VARCHAR(64) DEFAULT NULL"),
# 性能测试:增强指标体系(P95/标准差/延迟/连接时间/带宽/峰值TPS/Apdex/错误分类,旧库升级)
("performance_tasks", "p95_response_time", "FLOAT DEFAULT 0.0"),
("performance_tasks", "std_dev", "FLOAT DEFAULT 0.0"),
("performance_tasks", "latency_avg", "FLOAT DEFAULT 0.0"),
("performance_tasks", "latency_min", "FLOAT DEFAULT 0.0"),
("performance_tasks", "latency_max", "FLOAT DEFAULT 0.0"),
("performance_tasks", "connect_time_avg", "FLOAT DEFAULT 0.0"),
("performance_tasks", "connect_time_max", "FLOAT DEFAULT 0.0"),
("performance_tasks", "peak_tps", "FLOAT DEFAULT 0.0"),
("performance_tasks", "total_sent_bytes", "INTEGER DEFAULT 0"),
("performance_tasks", "total_received_bytes", "INTEGER DEFAULT 0"),
("performance_tasks", "apdex", "FLOAT DEFAULT 1.0"),
("performance_tasks", "error_type_timeout", "INTEGER DEFAULT 0"),
("performance_tasks", "error_type_connect_error", "INTEGER DEFAULT 0"),
("performance_tasks", "error_type_client_error", "INTEGER DEFAULT 0"),
("performance_tasks", "error_type_http_4xx", "INTEGER DEFAULT 0"),
("performance_tasks", "error_type_http_5xx", "INTEGER DEFAULT 0"),
("performance_tasks", "error_type_assertion", "INTEGER DEFAULT 0"),
# 性能测试:快照增强指标(旧库升级)
("performance_snapshots", "p95_response_time", "FLOAT DEFAULT 0.0"),
("performance_snapshots", "std_dev", "FLOAT DEFAULT 0.0"),
("performance_snapshots", "latency_avg", "FLOAT DEFAULT 0.0"),
("performance_snapshots", "connect_time_avg", "FLOAT DEFAULT 0.0"),
("performance_snapshots", "sent_bytes", "INTEGER DEFAULT 0"),
("performance_snapshots", "received_bytes", "INTEGER DEFAULT 0"),
("performance_snapshots", "peak_tps", "FLOAT DEFAULT 0.0"),
# 预设配置:登录凭据名称/用户名/密码(旧库升级)
("api_presets", "credential_name", "VARCHAR(100) DEFAULT NULL"),
("api_presets", "login_username", "VARCHAR(200) DEFAULT NULL"),
("api_presets", "login_password", "VARCHAR(200) DEFAULT NULL"),
] ]
def _do_ensure(sync_conn) -> None: def _do_ensure(sync_conn) -> None:
......
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
import asyncio import asyncio
import json import json
import logging import logging
import math
import re import re
import threading import threading
import time import time
...@@ -64,18 +65,34 @@ class MetricsCollector: ...@@ -64,18 +65,34 @@ class MetricsCollector:
self._period_status_3xx = 0 self._period_status_3xx = 0
self._period_status_4xx = 0 self._period_status_4xx = 0
self._period_status_5xx = 0 self._period_status_5xx = 0
self._period_latencies: List[float] = []
self._period_connect_times: List[float] = []
self._period_sent_bytes = 0
self._period_received_bytes = 0
# 全局累计 # 全局累计
self.total_requests = 0 self.total_requests = 0
self.success_count = 0 self.success_count = 0
self.fail_count = 0 self.fail_count = 0
self.all_times: List[float] = [] self.all_times: List[float] = []
self.all_latencies: List[float] = []
self.all_connect_times: List[float] = []
self.total_sent_bytes = 0
self.total_received_bytes = 0
self.status_2xx = 0 self.status_2xx = 0
self.status_3xx = 0 self.status_3xx = 0
self.status_4xx = 0 self.status_4xx = 0
self.status_5xx = 0 self.status_5xx = 0
self._peak_tps = 0.0
self._error_counts: Dict[str, int] = {
"timeout": 0, "connect_error": 0, "client_error": 0,
"http_4xx": 0, "http_5xx": 0, "assertion_fail": 0,
}
def record(self, status: int, elapsed_ms: float, success: bool, error: str = "") -> None: def record(self, status: int, elapsed_ms: float, success: bool, error: str = "",
latency_ms: float = 0.0, connect_ms: float = 0.0,
sent_bytes: int = 0, received_bytes: int = 0,
error_type: str = "") -> None:
""" """
记录一次请求结果 记录一次请求结果
...@@ -84,6 +101,11 @@ class MetricsCollector: ...@@ -84,6 +101,11 @@ class MetricsCollector:
elapsed_ms: 响应时间(毫秒) elapsed_ms: 响应时间(毫秒)
success: 是否成功(断言通过) success: 是否成功(断言通过)
error: 错误描述 error: 错误描述
latency_ms: 首字节延迟(毫秒)
connect_ms: TCP 连接时间(毫秒)
sent_bytes: 发送字节数
received_bytes: 接收字节数
error_type: 错误类型(timeout/connect_error/client_error/assertion_fail)
""" """
with self._lock: with self._lock:
self.total_requests += 1 self.total_requests += 1
...@@ -96,6 +118,14 @@ class MetricsCollector: ...@@ -96,6 +118,14 @@ class MetricsCollector:
self._period_times.append(elapsed_ms) self._period_times.append(elapsed_ms)
self.all_times.append(elapsed_ms) self.all_times.append(elapsed_ms)
self._period_latencies.append(latency_ms)
self.all_latencies.append(latency_ms)
self._period_connect_times.append(connect_ms)
self.all_connect_times.append(connect_ms)
self._period_sent_bytes += sent_bytes
self.total_sent_bytes += sent_bytes
self._period_received_bytes += received_bytes
self.total_received_bytes += received_bytes
if 200 <= status < 300: if 200 <= status < 300:
self._period_status_2xx += 1 self._period_status_2xx += 1
...@@ -106,14 +136,27 @@ class MetricsCollector: ...@@ -106,14 +136,27 @@ class MetricsCollector:
elif 400 <= status < 500: elif 400 <= status < 500:
self._period_status_4xx += 1 self._period_status_4xx += 1
self.status_4xx += 1 self.status_4xx += 1
self._error_counts["http_4xx"] += 1
elif status >= 500: elif status >= 500:
self._period_status_5xx += 1 self._period_status_5xx += 1
self.status_5xx += 1 self.status_5xx += 1
self._error_counts["http_5xx"] += 1
# 错误分类统计
if error_type:
self._error_counts[error_type] = self._error_counts.get(error_type, 0) + 1
elif not success and 200 <= status < 400:
# 断言失败(状态码成功但断言未通过)
self._error_counts["assertion_fail"] += 1
def snapshot(self) -> dict: def snapshot(self, peak_tps: float = 0.0, concurrent_users: int = 0) -> dict:
""" """
采集当前周期快照并重置周期计数器 采集当前周期快照并重置周期计数器
Args:
peak_tps: 当前峰值 TPS(由执行器维护)
concurrent_users: 实时并发数(在途请求数,由执行器维护)
Returns: Returns:
dict: 快照数据(含周期统计和全局累计) dict: 快照数据(含周期统计和全局累计)
""" """
...@@ -122,12 +165,15 @@ class MetricsCollector: ...@@ -122,12 +165,15 @@ class MetricsCollector:
period_times = self._period_times period_times = self._period_times
period_success = self._period_success period_success = self._period_success
period_fail = self._period_fail period_fail = self._period_fail
period_latencies = self._period_latencies
period_connect_times = self._period_connect_times
# 计算周期统计 # 计算周期统计
tps = period_success / 1.0 if period_success > 0 else 0 tps = period_success / 1.0 if period_success > 0 else 0
avg = sum(period_times) / len(period_times) if period_times else 0 avg = sum(period_times) / len(period_times) if period_times else 0
p50 = self._percentile(period_times, 50) if period_times else 0 p50 = self._percentile(period_times, 50) if period_times else 0
p90 = self._percentile(period_times, 90) if period_times else 0 p90 = self._percentile(period_times, 90) if period_times else 0
p95 = self._percentile(period_times, 95) if period_times else 0
p99 = self._percentile(period_times, 99) if period_times else 0 p99 = self._percentile(period_times, 99) if period_times else 0
snapshot = { snapshot = {
...@@ -138,7 +184,14 @@ class MetricsCollector: ...@@ -138,7 +184,14 @@ class MetricsCollector:
"max_response_time": round(max(period_times), 2) if period_times else 0, "max_response_time": round(max(period_times), 2) if period_times else 0,
"p50": round(p50, 2), "p50": round(p50, 2),
"p90": round(p90, 2), "p90": round(p90, 2),
"p95": round(p95, 2),
"p99": round(p99, 2), "p99": round(p99, 2),
"std_dev": round(self._std_dev(period_times), 2),
"latency_avg": round(sum(period_latencies) / len(period_latencies), 2) if period_latencies else 0,
"connect_time_avg": round(sum(period_connect_times) / len(period_connect_times), 2) if period_connect_times else 0,
"sent_bytes": self._period_sent_bytes,
"received_bytes": self._period_received_bytes,
"peak_tps": round(peak_tps, 2),
"success_count": period_success, "success_count": period_success,
"fail_count": period_fail, "fail_count": period_fail,
"status_2xx": self._period_status_2xx, "status_2xx": self._period_status_2xx,
...@@ -146,7 +199,7 @@ class MetricsCollector: ...@@ -146,7 +199,7 @@ class MetricsCollector:
"status_4xx": self._period_status_4xx, "status_4xx": self._period_status_4xx,
"status_5xx": self._period_status_5xx, "status_5xx": self._period_status_5xx,
"total_requests": self.total_requests, "total_requests": self.total_requests,
"concurrent_users": 0, # 由调用者填充 "concurrent_users": concurrent_users,
} }
# 重置周期计数器 # 重置周期计数器
...@@ -157,6 +210,10 @@ class MetricsCollector: ...@@ -157,6 +210,10 @@ class MetricsCollector:
self._period_status_3xx = 0 self._period_status_3xx = 0
self._period_status_4xx = 0 self._period_status_4xx = 0
self._period_status_5xx = 0 self._period_status_5xx = 0
self._period_latencies = []
self._period_connect_times = []
self._period_sent_bytes = 0
self._period_received_bytes = 0
return snapshot return snapshot
...@@ -168,6 +225,8 @@ class MetricsCollector: ...@@ -168,6 +225,8 @@ class MetricsCollector:
dict: 完整汇总数据 dict: 完整汇总数据
""" """
times = self.all_times times = self.all_times
latencies = self.all_latencies
connect_times = self.all_connect_times
elapsed = max(time.time() - self._start_time, 1) elapsed = max(time.time() - self._start_time, 1)
return { return {
"total_requests": self.total_requests, "total_requests": self.total_requests,
...@@ -179,14 +238,74 @@ class MetricsCollector: ...@@ -179,14 +238,74 @@ class MetricsCollector:
"avg_response_time": round(sum(times) / len(times), 2) if times else 0, "avg_response_time": round(sum(times) / len(times), 2) if times else 0,
"p50_response_time": round(self._percentile(times, 50), 2) if times else 0, "p50_response_time": round(self._percentile(times, 50), 2) if times else 0,
"p90_response_time": round(self._percentile(times, 90), 2) if times else 0, "p90_response_time": round(self._percentile(times, 90), 2) if times else 0,
"p95_response_time": round(self._percentile(times, 95), 2) if times else 0,
"p99_response_time": round(self._percentile(times, 99), 2) if times else 0, "p99_response_time": round(self._percentile(times, 99), 2) if times else 0,
"std_dev": round(self._std_dev(times), 2) if times else 0,
"latency_avg": round(sum(latencies) / len(latencies), 2) if latencies else 0,
"latency_min": round(min(latencies), 2) if latencies else 0,
"latency_max": round(max(latencies), 2) if latencies else 0,
"connect_time_avg": round(sum(connect_times) / len(connect_times), 2) if connect_times else 0,
"connect_time_max": round(max(connect_times), 2) if connect_times else 0,
"peak_tps": round(self._peak_tps, 2),
"total_sent_bytes": self.total_sent_bytes,
"total_received_bytes": self.total_received_bytes,
"sent_kb_per_sec": round(self.total_sent_bytes / 1024 / elapsed, 2),
"received_kb_per_sec": round(self.total_received_bytes / 1024 / elapsed, 2),
"apdex": round(self._apdex(times), 4),
"actual_tps": round(self.success_count / elapsed, 2), "actual_tps": round(self.success_count / elapsed, 2),
"status_2xx": self.status_2xx, "status_2xx": self.status_2xx,
"status_3xx": self.status_3xx, "status_3xx": self.status_3xx,
"status_4xx": self.status_4xx, "status_4xx": self.status_4xx,
"status_5xx": self.status_5xx, "status_5xx": self.status_5xx,
"error_type_timeout": self._error_counts.get("timeout", 0),
"error_type_connect_error": self._error_counts.get("connect_error", 0),
"error_type_client_error": self._error_counts.get("client_error", 0),
"error_type_http_4xx": self._error_counts.get("http_4xx", 0),
"error_type_http_5xx": self._error_counts.get("http_5xx", 0),
"error_type_assertion": self._error_counts.get("assertion_fail", 0),
} }
@staticmethod
def _std_dev(data: List[float]) -> float:
"""
计算总体标准差
Args:
data: 数据列表
Returns:
float: 标准差
"""
if len(data) < 2:
return 0.0
mean = sum(data) / len(data)
variance = sum((x - mean) ** 2 for x in data) / len(data)
return math.sqrt(variance)
@staticmethod
def _apdex(times: List[float], threshold_ms: float = 500) -> float:
"""
计算 Apdex(应用性能指数)
Apdex = (满意数 + 容忍数 / 2) / 总请求数
满意: 响应时间 <= threshold_ms
容忍: 响应时间 <= 4 * threshold_ms
Args:
times: 响应时间列表(毫秒)
threshold_ms: 满意阈值(默认 500ms)
Returns:
float: Apdex 值(0.0 ~ 1.0)
"""
if not times:
return 1.0
tolerated = 4 * threshold_ms
satisfied = sum(1 for t in times if t <= threshold_ms)
tolerating = sum(1 for t in times if threshold_ms < t <= tolerated)
total = len(times)
return (satisfied + tolerating / 2) / total
@staticmethod @staticmethod
def _percentile(data: List[float], p: float) -> float: def _percentile(data: List[float], p: float) -> float:
""" """
...@@ -397,6 +516,10 @@ class PerformanceExecutor: ...@@ -397,6 +516,10 @@ class PerformanceExecutor:
self._running = False self._running = False
self._metrics: Optional[MetricsCollector] = None self._metrics: Optional[MetricsCollector] = None
self._validator = ResultValidator() self._validator = ResultValidator()
# 实时并发数(在途请求数)
self._inflight = 0
# 峰值 TPS(跨周期比较)
self._peak_tps = 0.0
# 登录接口压测时复用的验证码 UUID(首次获取,后续复用) # 登录接口压测时复用的验证码 UUID(首次获取,后续复用)
self._login_uuid: Optional[str] = None self._login_uuid: Optional[str] = None
# 模板变量解析器(REF 占位符通过 ref_provider 跨任务取值) # 模板变量解析器(REF 占位符通过 ref_provider 跨任务取值)
...@@ -883,7 +1006,13 @@ class PerformanceExecutor: ...@@ -883,7 +1006,13 @@ class PerformanceExecutor:
status = 0 status = 0
body = "" body = ""
elapsed_ms = 0.0 elapsed_ms = 0.0
latency_ms = 0.0
connect_ms = 0.0
sent_bytes = 0
received_bytes = 0
# 实时并发数(在途请求数)统计
self._inflight += 1
try: try:
method = task.method.upper() method = task.method.upper()
url = task.target_url url = task.target_url
...@@ -904,6 +1033,11 @@ class PerformanceExecutor: ...@@ -904,6 +1033,11 @@ class PerformanceExecutor:
headers = self._build_headers(task, body_for_sign=json_data) headers = self._build_headers(task, body_for_sign=json_data)
# 发送字节数估算(GET/无请求体时为 0)
if json_data is not None:
sent_bytes = len(json.dumps(json_data, ensure_ascii=False).encode("utf-8"))
connect_start = time.time()
async with session.request( async with session.request(
method, url, method, url,
headers=headers, headers=headers,
...@@ -911,8 +1045,14 @@ class PerformanceExecutor: ...@@ -911,8 +1045,14 @@ class PerformanceExecutor:
ssl=False, ssl=False,
timeout=aiohttp.ClientTimeout(total=30), timeout=aiohttp.ClientTimeout(total=30),
) as resp: ) as resp:
# 进入上下文:已收到响应头 → 计算首字节延迟与 TCP 连接时间
headers_received_time = time.time()
latency_ms = (headers_received_time - start) * 1000
connect_ms = (headers_received_time - connect_start) * 1000
status = resp.status status = resp.status
body = await resp.text() body = await resp.text()
received_bytes = len(body.encode("utf-8"))
elapsed_ms = (time.time() - start) * 1000 elapsed_ms = (time.time() - start) * 1000
# 执行断言 # 执行断言
...@@ -921,20 +1061,41 @@ class PerformanceExecutor: ...@@ -921,20 +1061,41 @@ class PerformanceExecutor:
) )
# 记录指标 # 记录指标
self._metrics.record(status, elapsed_ms, assert_ok) self._metrics.record(
status, elapsed_ms, assert_ok,
latency_ms=latency_ms, connect_ms=connect_ms,
sent_bytes=sent_bytes, received_bytes=received_bytes,
)
# 响应捕获(从响应中提取字段保存到 _captured) # 响应捕获(从响应中提取字段保存到 _captured)
self._capture_response(task.capture_rules, body, status) self._capture_response(task.capture_rules, body, status)
except asyncio.TimeoutError: except asyncio.TimeoutError:
elapsed_ms = (time.time() - start) * 1000 elapsed_ms = (time.time() - start) * 1000
self._metrics.record(0, elapsed_ms, False, "timeout") self._metrics.record(
0, elapsed_ms, False, "timeout",
sent_bytes=sent_bytes, error_type="timeout",
)
except aiohttp.ClientConnectorError as e:
elapsed_ms = (time.time() - start) * 1000
self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="connect_error",
)
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
elapsed_ms = (time.time() - start) * 1000 elapsed_ms = (time.time() - start) * 1000
self._metrics.record(0, elapsed_ms, False, str(e)) self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="client_error",
)
except Exception as e: except Exception as e:
elapsed_ms = (time.time() - start) * 1000 elapsed_ms = (time.time() - start) * 1000
self._metrics.record(0, elapsed_ms, False, str(e)) self._metrics.record(
0, elapsed_ms, False, str(e),
sent_bytes=sent_bytes, error_type="client_error",
)
finally:
self._inflight -= 1
async def _sleep_with_check(self, seconds: float) -> None: async def _sleep_with_check(self, seconds: float) -> None:
""" """
...@@ -960,7 +1121,13 @@ class PerformanceExecutor: ...@@ -960,7 +1121,13 @@ class PerformanceExecutor:
while self._running: while self._running:
await asyncio.sleep(1) await asyncio.sleep(1)
try: try:
snapshot = self._metrics.snapshot() # 更新峰值 TPS(比较上一周期的 tps)
snapshot = self._metrics.snapshot(
peak_tps=self._peak_tps,
concurrent_users=self._inflight,
)
if snapshot.get("tps", 0) > self._peak_tps:
self._peak_tps = snapshot["tps"]
if self._metrics.snapshot_callback: if self._metrics.snapshot_callback:
self._metrics.snapshot_callback(snapshot) self._metrics.snapshot_callback(snapshot)
except Exception as e: except Exception as e:
......
...@@ -70,6 +70,17 @@ class ApiPreset(Base): ...@@ -70,6 +70,17 @@ class ApiPreset(Base):
) )
account_key: Mapped[str] = mapped_column(String(50), default="superadmin", comment="使用账号key") account_key: Mapped[str] = mapped_column(String(50), default="superadmin", comment="使用账号key")
# 登录凭据配置
credential_name: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True, default=None, comment="凭据名称,如'超级管理员'"
)
login_username: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录用户名,留空使用默认"
)
login_password: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录密码,留空使用默认"
)
# 响应捕获 # 响应捕获
capture_rules: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="捕获规则列表") capture_rules: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="捕获规则列表")
...@@ -104,6 +115,9 @@ class ApiPreset(Base): ...@@ -104,6 +115,9 @@ class ApiPreset(Base):
"auth_required": self.auth_required, "auth_required": self.auth_required,
"sign_request": self.sign_request, "sign_request": self.sign_request,
"account_key": self.account_key, "account_key": self.account_key,
"credential_name": self.credential_name,
"login_username": self.login_username,
"login_password": self.login_password,
"capture_rules": self.capture_rules or [], "capture_rules": self.capture_rules or [],
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
......
...@@ -209,7 +209,24 @@ class PerformanceTask(Base): ...@@ -209,7 +209,24 @@ class PerformanceTask(Base):
"avg_response_time": self.avg_response_time, "avg_response_time": self.avg_response_time,
"p50_response_time": self.p50_response_time, "p50_response_time": self.p50_response_time,
"p90_response_time": self.p90_response_time, "p90_response_time": self.p90_response_time,
"p95_response_time": self.p95_response_time,
"p99_response_time": self.p99_response_time, "p99_response_time": self.p99_response_time,
"std_dev": self.std_dev,
"latency_avg": self.latency_avg,
"latency_min": self.latency_min,
"latency_max": self.latency_max,
"connect_time_avg": self.connect_time_avg,
"connect_time_max": self.connect_time_max,
"peak_tps": self.peak_tps,
"total_sent_bytes": self.total_sent_bytes,
"total_received_bytes": self.total_received_bytes,
"apdex": self.apdex,
"error_type_timeout": self.error_type_timeout,
"error_type_connect_error": self.error_type_connect_error,
"error_type_client_error": self.error_type_client_error,
"error_type_http_4xx": self.error_type_http_4xx,
"error_type_http_5xx": self.error_type_http_5xx,
"error_type_assertion": self.error_type_assertion,
"actual_tps": self.actual_tps, "actual_tps": self.actual_tps,
"error_rate": self.error_rate, "error_rate": self.error_rate,
"start_time": self.start_time.isoformat() if self.start_time else None, "start_time": self.start_time.isoformat() if self.start_time else None,
...@@ -263,6 +280,14 @@ class PerformanceSnapshot(Base): ...@@ -263,6 +280,14 @@ class PerformanceSnapshot(Base):
p50_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P50响应时间(ms)") p50_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P50响应时间(ms)")
p90_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P90响应时间(ms)") p90_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P90响应时间(ms)")
p99_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P99响应时间(ms)") p99_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P99响应时间(ms)")
# 增强指标(快照级)
p95_response_time: Mapped[float] = mapped_column(Float, default=0.0, comment="P95响应时间(ms)")
std_dev: Mapped[float] = mapped_column(Float, default=0.0, comment="响应时间标准差(ms)")
latency_avg: Mapped[float] = mapped_column(Float, default=0.0, comment="平均首字节延迟(ms)")
connect_time_avg: Mapped[float] = mapped_column(Float, default=0.0, comment="平均TCP连接时间(ms)")
sent_bytes: Mapped[int] = mapped_column(Integer, default=0, comment="周期发送字节数")
received_bytes: Mapped[int] = mapped_column(Integer, default=0, comment="周期接收字节数")
peak_tps: Mapped[float] = mapped_column(Float, default=0.0, comment="当前峰值TPS")
success_count: Mapped[int] = mapped_column(Integer, default=0, comment="本周期成功数") success_count: Mapped[int] = mapped_column(Integer, default=0, comment="本周期成功数")
fail_count: Mapped[int] = mapped_column(Integer, default=0, comment="本周期失败数") fail_count: Mapped[int] = mapped_column(Integer, default=0, comment="本周期失败数")
status_2xx: Mapped[int] = mapped_column(Integer, default=0, comment="2xx计数") status_2xx: Mapped[int] = mapped_column(Integer, default=0, comment="2xx计数")
...@@ -296,7 +321,14 @@ class PerformanceSnapshot(Base): ...@@ -296,7 +321,14 @@ class PerformanceSnapshot(Base):
"max_response_time": self.max_response_time, "max_response_time": self.max_response_time,
"p50_response_time": self.p50_response_time, "p50_response_time": self.p50_response_time,
"p90_response_time": self.p90_response_time, "p90_response_time": self.p90_response_time,
"p95_response_time": self.p95_response_time,
"p99_response_time": self.p99_response_time, "p99_response_time": self.p99_response_time,
"std_dev": self.std_dev,
"latency_avg": self.latency_avg,
"connect_time_avg": self.connect_time_avg,
"sent_bytes": self.sent_bytes,
"received_bytes": self.received_bytes,
"peak_tps": self.peak_tps,
"success_count": self.success_count, "success_count": self.success_count,
"fail_count": self.fail_count, "fail_count": self.fail_count,
"status_2xx": self.status_2xx, "status_2xx": self.status_2xx,
......
...@@ -43,6 +43,11 @@ class ApiPresetCreate(BaseModel): ...@@ -43,6 +43,11 @@ class ApiPresetCreate(BaseModel):
sign_request: bool = Field(False, description="是否需要签名") sign_request: bool = Field(False, description="是否需要签名")
account_key: str = Field("superadmin", description="使用账号 key") account_key: str = Field("superadmin", description="使用账号 key")
# 登录凭据配置
credential_name: Optional[str] = Field(None, description="凭据名称,如'超级管理员'")
login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认")
login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认")
# 响应捕获 # 响应捕获
capture_rules: Optional[List[CaptureRule]] = Field(None, description="捕获规则列表") capture_rules: Optional[List[CaptureRule]] = Field(None, description="捕获规则列表")
...@@ -61,6 +66,9 @@ class ApiPresetUpdate(BaseModel): ...@@ -61,6 +66,9 @@ class ApiPresetUpdate(BaseModel):
auth_required: Optional[bool] = Field(None, description="是否需要登录 Token") auth_required: Optional[bool] = Field(None, description="是否需要登录 Token")
sign_request: Optional[bool] = Field(None, description="是否需要签名") sign_request: Optional[bool] = Field(None, description="是否需要签名")
account_key: Optional[str] = Field(None, description="使用账号 key") account_key: Optional[str] = Field(None, description="使用账号 key")
credential_name: Optional[str] = Field(None, description="凭据名称")
login_username: Optional[str] = Field(None, description="自定义登录用户名")
login_password: Optional[str] = Field(None, description="自定义登录密码")
capture_rules: Optional[List[CaptureRule]] = Field(None, description="捕获规则列表") capture_rules: Optional[List[CaptureRule]] = Field(None, description="捕获规则列表")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
...@@ -81,6 +89,9 @@ class ApiPresetResponse(BaseModel): ...@@ -81,6 +89,9 @@ class ApiPresetResponse(BaseModel):
auth_required: bool = True auth_required: bool = True
sign_request: bool = False sign_request: bool = False
account_key: str = "superadmin" account_key: str = "superadmin"
credential_name: Optional[str] = None
login_username: Optional[str] = None
login_password: Optional[str] = None
capture_rules: List[Dict[str, Any]] = [] capture_rules: List[Dict[str, Any]] = []
created_at: Optional[str] = None created_at: Optional[str] = None
updated_at: Optional[str] = None updated_at: Optional[str] = None
...@@ -95,6 +106,7 @@ class ApiPresetListItem(BaseModel): ...@@ -95,6 +106,7 @@ class ApiPresetListItem(BaseModel):
description: str = "" description: str = ""
method: str = "GET" method: str = "GET"
target_url: str = "" target_url: str = ""
credential_name: Optional[str] = None
updated_at: Optional[str] = None updated_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......
...@@ -166,9 +166,26 @@ class PerformanceTaskResponse(BaseModel): ...@@ -166,9 +166,26 @@ class PerformanceTaskResponse(BaseModel):
avg_response_time: float = 0.0 avg_response_time: float = 0.0
p50_response_time: float = 0.0 p50_response_time: float = 0.0
p90_response_time: float = 0.0 p90_response_time: float = 0.0
p95_response_time: float = 0.0
p99_response_time: float = 0.0 p99_response_time: float = 0.0
std_dev: float = 0.0
latency_avg: float = 0.0
latency_min: float = 0.0
latency_max: float = 0.0
connect_time_avg: float = 0.0
connect_time_max: float = 0.0
peak_tps: float = 0.0
total_sent_bytes: int = 0
total_received_bytes: int = 0
apdex: float = 1.0
actual_tps: float = 0.0 actual_tps: float = 0.0
error_rate: float = 0.0 error_rate: float = 0.0
error_type_timeout: int = 0
error_type_connect_error: int = 0
error_type_client_error: int = 0
error_type_http_4xx: int = 0
error_type_http_5xx: int = 0
error_type_assertion: int = 0
# 时间 # 时间
start_time: Optional[str] = None start_time: Optional[str] = None
...@@ -189,7 +206,9 @@ class PerformanceTaskListItem(BaseModel): ...@@ -189,7 +206,9 @@ class PerformanceTaskListItem(BaseModel):
target_url: str target_url: str
total_requests: int = 0 total_requests: int = 0
actual_tps: float = 0.0 actual_tps: float = 0.0
peak_tps: float = 0.0
avg_response_time: float = 0.0 avg_response_time: float = 0.0
p95_response_time: float = 0.0
error_rate: float = 0.0 error_rate: float = 0.0
duration_actual: float = 0.0 duration_actual: float = 0.0
start_time: Optional[str] = None start_time: Optional[str] = None
...@@ -232,7 +251,14 @@ class PerformanceSnapshotResponse(BaseModel): ...@@ -232,7 +251,14 @@ class PerformanceSnapshotResponse(BaseModel):
max_response_time: float = 0.0 max_response_time: float = 0.0
p50_response_time: float = 0.0 p50_response_time: float = 0.0
p90_response_time: float = 0.0 p90_response_time: float = 0.0
p95_response_time: float = 0.0
p99_response_time: float = 0.0 p99_response_time: float = 0.0
std_dev: float = 0.0
latency_avg: float = 0.0
connect_time_avg: float = 0.0
sent_bytes: int = 0
received_bytes: int = 0
peak_tps: float = 0.0
success_count: int = 0 success_count: int = 0
fail_count: int = 0 fail_count: int = 0
status_2xx: int = Field(0, alias="status2xx") status_2xx: int = Field(0, alias="status2xx")
...@@ -269,16 +295,35 @@ class PerformanceReportMetrics(BaseModel): ...@@ -269,16 +295,35 @@ class PerformanceReportMetrics(BaseModel):
fail_count: int = 0 fail_count: int = 0
error_rate: float = 0.0 error_rate: float = 0.0
actual_tps: float = 0.0 actual_tps: float = 0.0
peak_tps: float = 0.0
min_response_time: float = 0.0 min_response_time: float = 0.0
max_response_time: float = 0.0 max_response_time: float = 0.0
avg_response_time: float = 0.0 avg_response_time: float = 0.0
p50_response_time: float = 0.0 p50_response_time: float = 0.0
p90_response_time: float = 0.0 p90_response_time: float = 0.0
p95_response_time: float = 0.0
p99_response_time: float = 0.0 p99_response_time: float = 0.0
std_dev: float = 0.0
latency_avg: float = 0.0
latency_min: float = 0.0
latency_max: float = 0.0
connect_time_avg: float = 0.0
connect_time_max: float = 0.0
apdex: float = 1.0
total_sent_bytes: int = 0
total_received_bytes: int = 0
sent_kb_per_sec: float = 0.0
received_kb_per_sec: float = 0.0
status_2xx: int = Field(0, alias="status2xx") status_2xx: int = Field(0, alias="status2xx")
status_3xx: int = Field(0, alias="status3xx") status_3xx: int = Field(0, alias="status3xx")
status_4xx: int = Field(0, alias="status4xx") status_4xx: int = Field(0, alias="status4xx")
status_5xx: int = Field(0, alias="status5xx") status_5xx: int = Field(0, alias="status5xx")
error_type_timeout: int = 0
error_type_connect_error: int = 0
error_type_client_error: int = 0
error_type_http_4xx: int = 0
error_type_http_5xx: int = 0
error_type_assertion: int = 0
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......
...@@ -199,6 +199,11 @@ class PerformanceService: ...@@ -199,6 +199,11 @@ class PerformanceService:
data["sign_request"] = preset.sign_request data["sign_request"] = preset.sign_request
if not data.get("account_key"): if not data.get("account_key"):
data["account_key"] = preset.account_key data["account_key"] = preset.account_key
# 登录凭据:任务显式传入的优先,否则用预设值(预设留空则不覆盖)
if not data.get("login_username") and preset.login_username:
data["login_username"] = preset.login_username
if not data.get("login_password") and preset.login_password:
data["login_password"] = preset.login_password
return data return data
@staticmethod @staticmethod
...@@ -235,6 +240,11 @@ class PerformanceService: ...@@ -235,6 +240,11 @@ class PerformanceService:
else preset.sign_request else preset.sign_request
) )
data["account_key"] = data.get("account_key") or preset.account_key data["account_key"] = data.get("account_key") or preset.account_key
# 登录凭据:任务显式传入的优先,否则用预设值(预设留空则不覆盖)
if not data.get("login_username") and preset.login_username:
data["login_username"] = preset.login_username
if not data.get("login_password") and preset.login_password:
data["login_password"] = preset.login_password
return data return data
async def update_task( async def update_task(
...@@ -579,16 +589,35 @@ class PerformanceService: ...@@ -579,16 +589,35 @@ class PerformanceService:
"fail_count": task.fail_count, "fail_count": task.fail_count,
"error_rate": task.error_rate, "error_rate": task.error_rate,
"actual_tps": task.actual_tps, "actual_tps": task.actual_tps,
"peak_tps": task.peak_tps,
"min_response_time": task.min_response_time, "min_response_time": task.min_response_time,
"max_response_time": task.max_response_time, "max_response_time": task.max_response_time,
"avg_response_time": task.avg_response_time, "avg_response_time": task.avg_response_time,
"p50_response_time": task.p50_response_time, "p50_response_time": task.p50_response_time,
"p90_response_time": task.p90_response_time, "p90_response_time": task.p90_response_time,
"p95_response_time": task.p95_response_time,
"p99_response_time": task.p99_response_time, "p99_response_time": task.p99_response_time,
"std_dev": task.std_dev,
"latency_avg": task.latency_avg,
"latency_min": task.latency_min,
"latency_max": task.latency_max,
"connect_time_avg": task.connect_time_avg,
"connect_time_max": task.connect_time_max,
"apdex": task.apdex,
"total_sent_bytes": task.total_sent_bytes,
"total_received_bytes": task.total_received_bytes,
"sent_kb_per_sec": round(task.total_sent_bytes / 1024 / task.duration_actual, 2) if task.duration_actual > 0 else 0.0,
"received_kb_per_sec": round(task.total_received_bytes / 1024 / task.duration_actual, 2) if task.duration_actual > 0 else 0.0,
"status_2xx": status_2xx, "status_2xx": status_2xx,
"status_3xx": status_3xx, "status_3xx": status_3xx,
"status_4xx": status_4xx, "status_4xx": status_4xx,
"status_5xx": status_5xx, "status_5xx": status_5xx,
"error_type_timeout": task.error_type_timeout,
"error_type_connect_error": task.error_type_connect_error,
"error_type_client_error": task.error_type_client_error,
"error_type_http_4xx": task.error_type_http_4xx,
"error_type_http_5xx": task.error_type_http_5xx,
"error_type_assertion": task.error_type_assertion,
}, },
"snapshots": [s.to_dict() for s in snapshots], "snapshots": [s.to_dict() for s in snapshots],
} }
...@@ -649,7 +678,24 @@ class PerformanceService: ...@@ -649,7 +678,24 @@ class PerformanceService:
task.avg_response_time = result.get("avg_response_time", 0.0) task.avg_response_time = result.get("avg_response_time", 0.0)
task.p50_response_time = result.get("p50_response_time", 0.0) task.p50_response_time = result.get("p50_response_time", 0.0)
task.p90_response_time = result.get("p90_response_time", 0.0) task.p90_response_time = result.get("p90_response_time", 0.0)
task.p95_response_time = result.get("p95_response_time", 0.0)
task.p99_response_time = result.get("p99_response_time", 0.0) task.p99_response_time = result.get("p99_response_time", 0.0)
task.std_dev = result.get("std_dev", 0.0)
task.latency_avg = result.get("latency_avg", 0.0)
task.latency_min = result.get("latency_min", 0.0)
task.latency_max = result.get("latency_max", 0.0)
task.connect_time_avg = result.get("connect_time_avg", 0.0)
task.connect_time_max = result.get("connect_time_max", 0.0)
task.peak_tps = result.get("peak_tps", 0.0)
task.total_sent_bytes = result.get("total_sent_bytes", 0)
task.total_received_bytes = result.get("total_received_bytes", 0)
task.apdex = result.get("apdex", 1.0)
task.error_type_timeout = result.get("error_type_timeout", 0)
task.error_type_connect_error = result.get("error_type_connect_error", 0)
task.error_type_client_error = result.get("error_type_client_error", 0)
task.error_type_http_4xx = result.get("error_type_http_4xx", 0)
task.error_type_http_5xx = result.get("error_type_http_5xx", 0)
task.error_type_assertion = result.get("error_type_assertion", 0)
task.actual_tps = result.get("actual_tps", 0.0) task.actual_tps = result.get("actual_tps", 0.0)
task.error_rate = result.get("error_rate", 0.0) task.error_rate = result.get("error_rate", 0.0)
...@@ -682,7 +728,14 @@ class PerformanceService: ...@@ -682,7 +728,14 @@ class PerformanceService:
max_response_time=s.get("max_response_time", 0.0), max_response_time=s.get("max_response_time", 0.0),
p50_response_time=s.get("p50", 0.0), p50_response_time=s.get("p50", 0.0),
p90_response_time=s.get("p90", 0.0), p90_response_time=s.get("p90", 0.0),
p95_response_time=s.get("p95", 0.0),
p99_response_time=s.get("p99", 0.0), p99_response_time=s.get("p99", 0.0),
std_dev=s.get("std_dev", 0.0),
latency_avg=s.get("latency_avg", 0.0),
connect_time_avg=s.get("connect_time_avg", 0.0),
sent_bytes=s.get("sent_bytes", 0),
received_bytes=s.get("received_bytes", 0),
peak_tps=s.get("peak_tps", 0.0),
success_count=s.get("success_count", 0), success_count=s.get("success_count", 0),
fail_count=s.get("fail_count", 0), fail_count=s.get("fail_count", 0),
status_2xx=s.get("status_2xx", 0), status_2xx=s.get("status_2xx", 0),
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_performance_executor.py
模块描述:测试性能测试执行器与指标采集(增强指标体系)
覆盖指标:P95/标准差/Apdex/延迟/连接时间/字节数/峰值TPS/错误分类
作者:czj
创建日期:2026-08-20
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from app.executors.performance_executor import MetricsCollector
class TestStdDev:
"""测试总体标准差计算"""
def test_less_than_two_values(self):
assert MetricsCollector._std_dev([]) == 0.0
assert MetricsCollector._std_dev([10.0]) == 0.0
def test_constant_values(self):
assert MetricsCollector._std_dev([5.0, 5.0, 5.0]) == 0.0
def test_known_values(self):
# 数据: 2, 4, 4, 4, 5, 5, 7, 9 → mean=5, variance=4, std=2.0
data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]
assert MetricsCollector._std_dev(data) == pytest.approx(2.0, abs=1e-6)
class TestApdex:
"""测试 Apdex 计算"""
def test_empty_returns_one(self):
assert MetricsCollector._apdex([]) == 1.0
def test_all_satisfied(self):
# 全部在 500ms 内 → 1.0
times = [100.0, 200.0, 300.0, 500.0]
assert MetricsCollector._apdex(times) == pytest.approx(1.0)
def test_all_tolerating(self):
# 全部在 500~2000ms 之间 → 0.5
times = [600.0, 700.0, 800.0, 1000.0]
assert MetricsCollector._apdex(times) == pytest.approx(0.5)
def test_mixed(self):
# 满意2 + 容忍1 + 超出1 → (2 + 1/2) / 4 = 0.625
times = [100.0, 300.0, 800.0, 3000.0]
assert MetricsCollector._apdex(times) == pytest.approx(0.625)
def test_custom_threshold(self):
times = [200.0, 400.0]
assert MetricsCollector._apdex(times, threshold_ms=300) == pytest.approx(0.75)
class TestPercentile:
"""测试百分位数(线性插值)"""
def test_single_value(self):
assert MetricsCollector._percentile([42.0], 95) == pytest.approx(42.0)
def test_empty(self):
assert MetricsCollector._percentile([], 95) == 0.0
def test_known_values(self):
# 数据 [1, 2, 3, 4]:P50 = 2.5(线性插值)
data = [1.0, 2.0, 3.0, 4.0]
assert MetricsCollector._percentile(data, 50) == pytest.approx(2.5)
# P100 = 4.0
assert MetricsCollector._percentile(data, 100) == pytest.approx(4.0)
def test_unsorted_input(self):
data = [4.0, 1.0, 3.0, 2.0]
assert MetricsCollector._percentile(data, 50) == pytest.approx(2.5)
class TestRecord:
"""测试 record() 指标记录与错误分类"""
def make_collector(self):
return MetricsCollector()
def test_success_record(self):
c = self.make_collector()
c.record(status=200, elapsed_ms=100.0, success=True,
latency_ms=50.0, connect_ms=10.0, sent_bytes=100, received_bytes=500)
assert c.total_requests == 1
assert c.success_count == 1
assert c.fail_count == 0
assert c.all_latencies == [50.0]
assert c.all_connect_times == [10.0]
assert c.total_sent_bytes == 100
assert c.total_received_bytes == 500
assert c.status_2xx == 1
def test_http_4xx_classification(self):
c = self.make_collector()
c.record(status=404, elapsed_ms=50.0, success=False, error='404 Not Found')
assert c.status_4xx == 1
assert c._error_counts["http_4xx"] == 1
def test_http_5xx_classification(self):
c = self.make_collector()
c.record(status=500, elapsed_ms=50.0, success=False, error='500 Server Error')
assert c.status_5xx == 1
assert c._error_counts["http_5xx"] == 1
def test_assertion_fail_classification(self):
# 状态码成功但断言未通过 → assertion_fail
c = self.make_collector()
c.record(status=200, elapsed_ms=50.0, success=False, error='断言失败: 返回值不匹配')
assert c._error_counts["assertion_fail"] == 1
def test_timeout_classification(self):
c = self.make_collector()
c.record(status=0, elapsed_ms=3000.0, success=False, error='timeout', error_type='timeout')
assert c._error_counts["timeout"] == 1
def test_connect_error_classification(self):
c = self.make_collector()
c.record(status=0, elapsed_ms=1.0, success=False, error='connect error', error_type='connect_error')
assert c._error_counts["connect_error"] == 1
class TestSnapshot:
"""测试 snapshot() 周期快照"""
def test_empty_snapshot(self):
c = MetricsCollector()
snap = c.snapshot(peak_tps=3.5, concurrent_users=2)
assert snap["elapsed"] >= 0
assert snap["tps"] == 0
assert snap["std_dev"] == 0
assert snap["latency_avg"] == 0
assert snap["connect_time_avg"] == 0
assert snap["sent_bytes"] == 0
assert snap["received_bytes"] == 0
assert snap["peak_tps"] == 3.5
assert snap["concurrent_users"] == 2
def test_snapshot_fields_and_reset(self):
c = MetricsCollector()
c.record(status=200, elapsed_ms=100.0, success=True, latency_ms=40.0,
connect_ms=5.0, sent_bytes=200, received_bytes=1000)
c.record(status=200, elapsed_ms=300.0, success=True, latency_ms=120.0,
connect_ms=15.0, sent_bytes=200, received_bytes=1000)
snap = c.snapshot(peak_tps=1.0, concurrent_users=1)
assert snap["tps"] == 2.0
# 平均响应时间 (100+300)/2
assert snap["avg_response_time"] == pytest.approx(200.0)
# 标准差 (100-200)^2 + (300-200)^2 → /2 → sqrt(10000) = 100
assert snap["std_dev"] == pytest.approx(100.0)
assert snap["latency_avg"] == pytest.approx(80.0)
assert snap["connect_time_avg"] == pytest.approx(10.0)
assert snap["sent_bytes"] == 400
assert snap["received_bytes"] == 2000
assert snap["p95"] == pytest.approx(290.0)
# 周期计数器已重置
assert c._period_success == 0
assert c._period_times == []
assert c._period_sent_bytes == 0
# 全局累计保留
assert c.total_requests == 2
assert c.total_sent_bytes == 400
def test_snapshot_multiple_periods(self):
c = MetricsCollector()
c.record(status=200, elapsed_ms=100.0, success=True)
c.snapshot()
c.record(status=200, elapsed_ms=200.0, success=True)
snap = c.snapshot()
assert snap["tps"] == 1.0
assert snap["avg_response_time"] == pytest.approx(200.0)
assert c.total_requests == 2
class TestSummary:
"""测试 summary() 最终汇总"""
def test_empty_summary(self):
c = MetricsCollector()
s = c.summary()
assert s["total_requests"] == 0
assert s["error_rate"] == 0.0
assert s["p95_response_time"] == 0
assert s["std_dev"] == 0
assert s["apdex"] == pytest.approx(1.0)
assert s["error_type_timeout"] == 0
def test_summary_aggregation(self):
c = MetricsCollector()
# 100ms 成功 + 600ms 成功 + 5000ms 超时
c.record(status=200, elapsed_ms=100.0, success=True, latency_ms=40.0, connect_ms=5.0,
sent_bytes=100, received_bytes=1000)
c.record(status=200, elapsed_ms=600.0, success=True, latency_ms=200.0, connect_ms=20.0,
sent_bytes=100, received_bytes=2000)
c.record(status=0, elapsed_ms=5000.0, success=False, error='timeout', error_type='timeout',
latency_ms=5000.0, connect_ms=10.0, sent_bytes=100, received_bytes=0)
s = c.summary()
assert s["total_requests"] == 3
assert s["success_count"] == 2
assert s["fail_count"] == 1
assert s["error_rate"] == pytest.approx(100 / 3, abs=0.01)
assert s["p50_response_time"] == pytest.approx(600.0)
# 线性插值 P95(数据 [100, 600, 5000]):600 + (5000-600) * 0.9
assert s["p95_response_time"] == pytest.approx(4560.0)
assert s["latency_avg"] == pytest.approx((40 + 200 + 5000) / 3, abs=0.01)
assert s["connect_time_avg"] == pytest.approx((5 + 20 + 10) / 3, abs=0.01)
assert s["total_sent_bytes"] == 300
assert s["total_received_bytes"] == 3000
assert s["error_type_timeout"] == 1
# Apdex:满意1(100) + 容忍1(600) + 超出1(5000) → (1 + 0.5) / 3 = 0.5
assert s["apdex"] == pytest.approx(0.5, abs=0.01)
def test_summary_error_types(self):
c = MetricsCollector()
c.record(status=500, elapsed_ms=100.0, success=False, error='500')
c.record(status=404, elapsed_ms=100.0, success=False, error='404')
c.record(status=200, elapsed_ms=100.0, success=False, error='断言失败')
c.record(status=0, elapsed_ms=100.0, success=False, error='conn', error_type='connect_error')
s = c.summary()
assert s["error_type_http_5xx"] == 1
assert s["error_type_http_4xx"] == 1
assert s["error_type_assertion"] == 1
assert s["error_type_connect_error"] == 1
assert s["fail_count"] == 4
def test_bytes_kb_per_sec(self):
c = MetricsCollector()
c.record(status=200, elapsed_ms=100.0, success=True, sent_bytes=10240, received_bytes=5120)
s = c.summary()
# elapsed = max(实际耗时, 1s),测试进程远小于 1s → 速率为 10/1 = 10 KB/s;
# 慢机器上耗时略超 1s 时速率相应变小,故断言上界
assert s["sent_kb_per_sec"] > 0
assert s["sent_kb_per_sec"] <= 10.0
assert s["received_kb_per_sec"] > 0
assert s["received_kb_per_sec"] <= 5.0
\ No newline at end of file
...@@ -87,7 +87,7 @@ ...@@ -87,7 +87,7 @@
<el-menu-item index="/performance/tasks">任务管理</el-menu-item> <el-menu-item index="/performance/tasks">任务管理</el-menu-item>
<el-menu-item index="/performance/monitor">执行监控</el-menu-item> <el-menu-item index="/performance/monitor">执行监控</el-menu-item>
<el-menu-item index="/performance/report">报告查看</el-menu-item> <el-menu-item index="/performance/report">报告查看</el-menu-item>
<el-menu-item index="/performance/presets">接口预设</el-menu-item> <el-menu-item index="/performance/presets">预设配置</el-menu-item>
</el-sub-menu> </el-sub-menu>
<!-- 接口测试(一级菜单,含用例管理/执行中心/报告中心) --> <!-- 接口测试(一级菜单,含用例管理/执行中心/报告中心) -->
...@@ -283,7 +283,7 @@ const PERFORMANCE_LABELS: Record<string, string> = { ...@@ -283,7 +283,7 @@ const PERFORMANCE_LABELS: Record<string, string> = {
tasks: '任务管理', tasks: '任务管理',
monitor: '执行监控', monitor: '执行监控',
report: '报告查看', report: '报告查看',
presets: '接口预设', presets: '预设配置',
} }
/** 功能测试报告子页面标签映射 */ /** 功能测试报告子页面标签映射 */
......
...@@ -185,7 +185,7 @@ const routes: RouteRecordRaw[] = [ ...@@ -185,7 +185,7 @@ const routes: RouteRecordRaw[] = [
path: 'presets', path: 'presets',
name: 'PerfPresets', name: 'PerfPresets',
component: () => import('@/views/performance/ApiPresetList.vue'), component: () => import('@/views/performance/ApiPresetList.vue'),
meta: { title: '接口预设' } meta: { title: '预设配置' }
} }
] ]
}, },
......
...@@ -66,7 +66,24 @@ export interface PerformanceTask { ...@@ -66,7 +66,24 @@ export interface PerformanceTask {
avgResponseTime: number | null avgResponseTime: number | null
p50ResponseTime: number | null p50ResponseTime: number | null
p90ResponseTime: number | null p90ResponseTime: number | null
p95ResponseTime: number | null
p99ResponseTime: number | null p99ResponseTime: number | null
stdDev: number | null
latencyAvg: number | null
latencyMin: number | null
latencyMax: number | null
connectTimeAvg: number | null
connectTimeMax: number | null
peakTps: number | null
totalSentBytes: number | null
totalReceivedBytes: number | null
apdex: number | null
errorTypeTimeout: number | null
errorTypeConnectError: number | null
errorTypeClientError: number | null
errorTypeHttp4xx: number | null
errorTypeHttp5xx: number | null
errorTypeAssertion: number | null
actualTps: number | null actualTps: number | null
errorRate: number | null errorRate: number | null
startTime: string | null startTime: string | null
...@@ -141,6 +158,9 @@ export interface PerformanceTaskListItem { ...@@ -141,6 +158,9 @@ export interface PerformanceTaskListItem {
concurrency: number concurrency: number
duration: number duration: number
actualTps: number | null actualTps: number | null
peakTps: number | null
p95ResponseTime: number | null
avgResponseTime: number | null
errorRate: number | null errorRate: number | null
startTime: string | null startTime: string | null
endTime: string | null endTime: string | null
...@@ -177,7 +197,14 @@ export interface PerformanceSnapshot { ...@@ -177,7 +197,14 @@ export interface PerformanceSnapshot {
maxResponseTime: number maxResponseTime: number
p50ResponseTime: number p50ResponseTime: number
p90ResponseTime: number p90ResponseTime: number
p95ResponseTime: number
p99ResponseTime: number p99ResponseTime: number
stdDev: number
latencyAvg: number
connectTimeAvg: number
sentBytes: number
receivedBytes: number
peakTps: number
successCount: number successCount: number
failCount: number failCount: number
status2xx: number status2xx: number
...@@ -214,16 +241,35 @@ export interface PerformanceReportMetrics { ...@@ -214,16 +241,35 @@ export interface PerformanceReportMetrics {
failCount: number failCount: number
errorRate: number errorRate: number
actualTps: number actualTps: number
peakTps: number
minResponseTime: number minResponseTime: number
maxResponseTime: number maxResponseTime: number
avgResponseTime: number avgResponseTime: number
p50ResponseTime: number p50ResponseTime: number
p90ResponseTime: number p90ResponseTime: number
p95ResponseTime: number
p99ResponseTime: number p99ResponseTime: number
stdDev: number
latencyAvg: number
latencyMin: number
latencyMax: number
connectTimeAvg: number
connectTimeMax: number
apdex: number
totalSentBytes: number
totalReceivedBytes: number
sentKbPerSec: number
receivedKbPerSec: number
status2xx: number status2xx: number
status3xx: number status3xx: number
status4xx: number status4xx: number
status5xx: number status5xx: number
errorTypeTimeout: number
errorTypeConnectError: number
errorTypeClientError: number
errorTypeHttp4xx: number
errorTypeHttp5xx: number
errorTypeAssertion: number
} }
/** 报告响应 */ /** 报告响应 */
...@@ -243,10 +289,18 @@ export interface PerfWsSnapshot { ...@@ -243,10 +289,18 @@ export interface PerfWsSnapshot {
avgResponseTime: number avgResponseTime: number
p50: number p50: number
p90: number p90: number
p95: number
p99: number p99: number
stdDev: number
latencyAvg: number
connectTimeAvg: number
sentBytes: number
receivedBytes: number
peakTps: number
successCount: number successCount: number
failCount: number failCount: number
totalRequests: number totalRequests: number
concurrentUsers: number
status2xx: number status2xx: number
status4xx: number status4xx: number
status5xx: number status5xx: number
...@@ -277,6 +331,9 @@ export interface ApiPreset { ...@@ -277,6 +331,9 @@ export interface ApiPreset {
authRequired: boolean authRequired: boolean
signRequest: boolean signRequest: boolean
accountKey: string accountKey: string
credentialName: string | null
loginUsername: string | null
loginPassword: string | null
captureRules: any[] | null captureRules: any[] | null
createdAt: string | null createdAt: string | null
updatedAt: string | null updatedAt: string | null
...@@ -294,6 +351,9 @@ export interface ApiPresetCreate { ...@@ -294,6 +351,9 @@ export interface ApiPresetCreate {
authRequired?: boolean authRequired?: boolean
signRequest?: boolean signRequest?: boolean
accountKey?: string accountKey?: string
credentialName?: string | null
loginUsername?: string | null
loginPassword?: string | null
captureRules?: any[] | null captureRules?: any[] | null
} }
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<template> <template>
<el-dialog <el-dialog
v-model="visible" v-model="visible"
:title="isEdit ? '编辑接口预设' : '新建接口预设'" :title="isEdit ? '编辑预设配置' : '新建预设配置'"
width="680px" width="680px"
top="3vh" top="3vh"
:close-on-click-modal="false" :close-on-click-modal="false"
...@@ -107,6 +107,28 @@ ...@@ -107,6 +107,28 @@
</el-col> </el-col>
</el-row> </el-row>
<el-divider v-if="form.authRequired" style="margin: 8px 0" />
<template v-if="form.authRequired">
<div class="credential-section-label" style="font-size: 13px; color: #909399; margin-bottom: 8px;">登录凭据(可选,留空使用默认值)</div>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="凭据名称">
<el-input v-model="form.credentialName" placeholder="如:超级管理员" clearable />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="登录用户名">
<el-input v-model="form.loginUsername" placeholder="留空使用默认" clearable />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="登录密码">
<el-input v-model="form.loginPassword" type="password" show-password placeholder="留空使用默认" clearable />
</el-form-item>
</el-col>
</el-row>
</template>
<!-- 描述 --> <!-- 描述 -->
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="预设描述(可选)" /> <el-input v-model="form.description" type="textarea" :rows="2" placeholder="预设描述(可选)" />
...@@ -163,6 +185,9 @@ interface PresetForm { ...@@ -163,6 +185,9 @@ interface PresetForm {
authRequired: boolean authRequired: boolean
signRequest: boolean signRequest: boolean
accountKey: string accountKey: string
credentialName: string
loginUsername: string
loginPassword: string
} }
const defaultForm = (): PresetForm => ({ const defaultForm = (): PresetForm => ({
...@@ -177,6 +202,9 @@ const defaultForm = (): PresetForm => ({ ...@@ -177,6 +202,9 @@ const defaultForm = (): PresetForm => ({
authRequired: true, authRequired: true,
signRequest: false, signRequest: false,
accountKey: 'superadmin', accountKey: 'superadmin',
credentialName: '',
loginUsername: '',
loginPassword: '',
}) })
const form = reactive<PresetForm>(defaultForm()) const form = reactive<PresetForm>(defaultForm())
...@@ -224,6 +252,9 @@ function initForm() { ...@@ -224,6 +252,9 @@ function initForm() {
form.authRequired = p.authRequired form.authRequired = p.authRequired
form.signRequest = p.signRequest form.signRequest = p.signRequest
form.accountKey = p.accountKey || 'superadmin' form.accountKey = p.accountKey || 'superadmin'
form.credentialName = p.credentialName || ''
form.loginUsername = p.loginUsername || ''
form.loginPassword = p.loginPassword || ''
} else { } else {
isEdit.value = false isEdit.value = false
Object.assign(form, defaultForm()) Object.assign(form, defaultForm())
...@@ -255,6 +286,9 @@ async function handleSave() { ...@@ -255,6 +286,9 @@ async function handleSave() {
authRequired: form.authRequired, authRequired: form.authRequired,
signRequest: form.signRequest, signRequest: form.signRequest,
accountKey: form.authRequired ? form.accountKey : null, accountKey: form.authRequired ? form.accountKey : null,
credentialName: form.credentialName || null,
loginUsername: form.loginUsername || null,
loginPassword: form.loginPassword || null,
} }
if (form.bodyType === 'static' && form.body) { if (form.bodyType === 'static' && form.body) {
......
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
<!-- 提示 --> <!-- 提示 -->
<el-alert <el-alert
title="接口预设用于快速填充任务配置。修改或删除预设不影响已创建的任务。" title="预设配置用于快速填充任务配置,修改或删除预设不影响已创建的任务。"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
...@@ -42,6 +42,11 @@ ...@@ -42,6 +42,11 @@
<el-tag :type="methodTagType(row.method)" size="small">{{ row.method }}</el-tag> <el-tag :type="methodTagType(row.method)" size="small">{{ row.method }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="credentialName" label="凭据名称" width="150" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.credentialName || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="targetUrl" label="目标 URL" min-width="300" show-overflow-tooltip /> <el-table-column prop="targetUrl" label="目标 URL" min-width="300" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip> <el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
......
...@@ -55,25 +55,25 @@ ...@@ -55,25 +55,25 @@
<!-- 实时指标卡片 --> <!-- 实时指标卡片 -->
<el-row :gutter="12" class="metrics-row"> <el-row :gutter="12" class="metrics-row">
<el-col :span="6"> <el-col :span="4">
<el-card shadow="never" class="metric-card"> <el-card shadow="never" class="metric-card">
<div class="metric-value">{{ currentTps.toFixed(1) }}</div> <div class="metric-value">{{ currentTps.toFixed(1) }}</div>
<div class="metric-label">当前 TPS</div> <div class="metric-label">当前 TPS</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="4">
<el-card shadow="never" class="metric-card"> <el-card shadow="never" class="metric-card">
<div class="metric-value">{{ currentAvgRt.toFixed(0) }}ms</div> <div class="metric-value">{{ currentAvgRt.toFixed(0) }}ms</div>
<div class="metric-label">平均响应时间</div> <div class="metric-label">平均响应时间</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="4">
<el-card shadow="never" class="metric-card"> <el-card shadow="never" class="metric-card">
<div class="metric-value">{{ totalRequests }}</div> <div class="metric-value">{{ totalRequests }}</div>
<div class="metric-label">总请求数</div> <div class="metric-label">总请求数</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="4">
<el-card shadow="never" class="metric-card"> <el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: errorRate > 5 ? '#f56c6c' : '#67c23a' }"> <div class="metric-value" :style="{ color: errorRate > 5 ? '#f56c6c' : '#67c23a' }">
{{ errorRate.toFixed(1) }}% {{ errorRate.toFixed(1) }}%
...@@ -81,6 +81,18 @@ ...@@ -81,6 +81,18 @@
<div class="metric-label">错误率</div> <div class="metric-label">错误率</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="4">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ currentPeakTps.toFixed(1) }}</div>
<div class="metric-label">峰值 TPS</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ currentInflight }}</div>
<div class="metric-label">实时并发数</div>
</el-card>
</el-col>
</el-row> </el-row>
<!-- TPS 趋势图 --> <!-- TPS 趋势图 -->
...@@ -93,7 +105,7 @@ ...@@ -93,7 +105,7 @@
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="16"> <el-col :span="16">
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="chart-card">
<template #header><span>响应时间趋势</span></template> <template #header><span>响应时间趋势(含 P95)</span></template>
<div ref="rtChartRef" class="chart" /> <div ref="rtChartRef" class="chart" />
</el-card> </el-card>
</el-col> </el-col>
...@@ -104,6 +116,22 @@ ...@@ -104,6 +116,22 @@
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<!-- 延迟/带宽 + 并发 -->
<el-row :gutter="12">
<el-col :span="16">
<el-card shadow="never" class="chart-card">
<template #header><span>延迟与连接时间趋势</span></template>
<div ref="latencyChartRef" class="chart" />
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="chart-card">
<template #header><span>并发数趋势</span></template>
<div ref="inflightChartRef" class="chart" />
</el-card>
</el-col>
</el-row>
</template> </template>
</div> </div>
</template> </template>
...@@ -165,6 +193,8 @@ const taskStatus = ref<string>('') ...@@ -165,6 +193,8 @@ const taskStatus = ref<string>('')
// 实时指标 // 实时指标
const currentTps = ref(0) const currentTps = ref(0)
const currentAvgRt = ref(0) const currentAvgRt = ref(0)
const currentPeakTps = ref(0)
const currentInflight = ref(0)
const totalRequests = ref(0) const totalRequests = ref(0)
const errorRate = ref(0) const errorRate = ref(0)
...@@ -174,7 +204,11 @@ const tpsData = ref<number[]>([]) ...@@ -174,7 +204,11 @@ const tpsData = ref<number[]>([])
const avgRtData = ref<number[]>([]) const avgRtData = ref<number[]>([])
const p50Data = ref<number[]>([]) const p50Data = ref<number[]>([])
const p90Data = ref<number[]>([]) const p90Data = ref<number[]>([])
const p95Data = ref<number[]>([])
const p99Data = ref<number[]>([]) const p99Data = ref<number[]>([])
const latencyAvgData = ref<number[]>([])
const connectTimeAvgData = ref<number[]>([])
const inflightData = ref<number[]>([])
const status2xx = ref(0) const status2xx = ref(0)
const status4xx = ref(0) const status4xx = ref(0)
const status5xx = ref(0) const status5xx = ref(0)
...@@ -183,10 +217,14 @@ const status5xx = ref(0) ...@@ -183,10 +217,14 @@ const status5xx = ref(0)
const tpsChartRef = ref<HTMLElement>() const tpsChartRef = ref<HTMLElement>()
const rtChartRef = ref<HTMLElement>() const rtChartRef = ref<HTMLElement>()
const statusChartRef = ref<HTMLElement>() const statusChartRef = ref<HTMLElement>()
const latencyChartRef = ref<HTMLElement>()
const inflightChartRef = ref<HTMLElement>()
let tpsChart: echarts.ECharts | null = null let tpsChart: echarts.ECharts | null = null
let rtChart: echarts.ECharts | null = null let rtChart: echarts.ECharts | null = null
let statusChart: echarts.ECharts | null = null let statusChart: echarts.ECharts | null = null
let latencyChart: echarts.ECharts | null = null
let inflightChart: echarts.ECharts | null = null
let ws: WebSocket | null = null let ws: WebSocket | null = null
/** 状态标签类型 */ /** 状态标签类型 */
...@@ -212,6 +250,12 @@ function initCharts() { ...@@ -212,6 +250,12 @@ function initCharts() {
if (statusChartRef.value) { if (statusChartRef.value) {
statusChart = echarts.init(statusChartRef.value) statusChart = echarts.init(statusChartRef.value)
} }
if (latencyChartRef.value) {
latencyChart = echarts.init(latencyChartRef.value)
}
if (inflightChartRef.value) {
inflightChart = echarts.init(inflightChartRef.value)
}
} }
/** 更新 TPS 图表 */ /** 更新 TPS 图表 */
...@@ -244,7 +288,7 @@ function updateRtChart() { ...@@ -244,7 +288,7 @@ function updateRtChart() {
if (!rtChart) return if (!rtChart) return
const option: EChartsOption = { const option: EChartsOption = {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['平均', 'P50', 'P90', 'P99'], top: 0, itemWidth: 12, itemHeight: 8 }, legend: { data: ['平均', 'P50', 'P90', 'P95', 'P99'], top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 }, grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: timeLabels.value, axisLabel: { fontSize: 11 } }, xAxis: { type: 'category', data: timeLabels.value, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', name: 'ms' }, yAxis: { type: 'value', name: 'ms' },
...@@ -252,12 +296,51 @@ function updateRtChart() { ...@@ -252,12 +296,51 @@ function updateRtChart() {
{ name: '平均', type: 'line', data: avgRtData.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5 } }, { name: '平均', type: 'line', data: avgRtData.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5 } },
{ name: 'P50', type: 'line', data: p50Data.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dashed' } }, { name: 'P50', type: 'line', data: p50Data.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dashed' } },
{ name: 'P90', type: 'line', data: p90Data.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dashed' } }, { name: 'P90', type: 'line', data: p90Data.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dashed' } },
{ name: 'P95', type: 'line', data: p95Data.value, smooth: true, showSymbol: false, lineStyle: { width: 2, type: 'dashed', color: '#e6a23c' } },
{ name: 'P99', type: 'line', data: p99Data.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dotted' } }, { name: 'P99', type: 'line', data: p99Data.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dotted' } },
], ],
} }
rtChart.setOption(option, true) rtChart.setOption(option, true)
} }
/** 更新延迟与连接时间图表 */
function updateLatencyChart() {
if (!latencyChart) return
const option: EChartsOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['首字节延迟', '连接时间'], top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: timeLabels.value, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', name: 'ms' },
series: [
{ name: '首字节延迟', type: 'line', data: latencyAvgData.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5 }, areaStyle: { opacity: 0.08 } },
{ name: '连接时间', type: 'line', data: connectTimeAvgData.value, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dashed', color: '#e6a23c' } },
],
}
latencyChart.setOption(option, true)
}
/** 更新并发数图表 */
function updateInflightChart() {
if (!inflightChart) return
const option: EChartsOption = {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, bottom: 30, top: 10 },
xAxis: { type: 'category', data: timeLabels.value, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', name: '并发数', minInterval: 1 },
series: [{
type: 'line',
data: inflightData.value,
smooth: true,
showSymbol: false,
step: 'end',
lineStyle: { width: 2, color: '#9b59b6' },
areaStyle: { opacity: 0.12 },
}],
}
inflightChart.setOption(option, true)
}
/** 更新状态码饼图 */ /** 更新状态码饼图 */
function updateStatusChart() { function updateStatusChart() {
if (!statusChart) return if (!statusChart) return
...@@ -283,6 +366,8 @@ function handleSnapshot(data: any) { ...@@ -283,6 +366,8 @@ function handleSnapshot(data: any) {
const d = data.data || data const d = data.data || data
currentTps.value = d.tps || 0 currentTps.value = d.tps || 0
currentAvgRt.value = d.avgResponseTime || d.avg_response_time || 0 currentAvgRt.value = d.avgResponseTime || d.avg_response_time || 0
currentPeakTps.value = d.peakTps || d.peak_tps || 0
currentInflight.value = d.concurrentUsers || d.concurrent_users || 0
totalRequests.value = d.totalRequests || d.total_requests || 0 totalRequests.value = d.totalRequests || d.total_requests || 0
const fail = d.failCount || d.fail_count || 0 const fail = d.failCount || d.fail_count || 0
const total = totalRequests.value const total = totalRequests.value
...@@ -295,7 +380,11 @@ function handleSnapshot(data: any) { ...@@ -295,7 +380,11 @@ function handleSnapshot(data: any) {
avgRtData.value.push(d.avgResponseTime || d.avg_response_time || 0) avgRtData.value.push(d.avgResponseTime || d.avg_response_time || 0)
p50Data.value.push(d.p50 || 0) p50Data.value.push(d.p50 || 0)
p90Data.value.push(d.p90 || 0) p90Data.value.push(d.p90 || 0)
p95Data.value.push(d.p95 || 0)
p99Data.value.push(d.p99 || 0) p99Data.value.push(d.p99 || 0)
latencyAvgData.value.push(d.latencyAvg || d.latency_avg || 0)
connectTimeAvgData.value.push(d.connectTimeAvg || d.connect_time_avg || 0)
inflightData.value.push(d.concurrentUsers || d.concurrent_users || 0)
status2xx.value += d.status2xx || d.status_2xx || 0 status2xx.value += d.status2xx || d.status_2xx || 0
status4xx.value += d.status4xx || d.status_4xx || 0 status4xx.value += d.status4xx || d.status_4xx || 0
...@@ -308,13 +397,19 @@ function handleSnapshot(data: any) { ...@@ -308,13 +397,19 @@ function handleSnapshot(data: any) {
avgRtData.value.shift() avgRtData.value.shift()
p50Data.value.shift() p50Data.value.shift()
p90Data.value.shift() p90Data.value.shift()
p95Data.value.shift()
p99Data.value.shift() p99Data.value.shift()
latencyAvgData.value.shift()
connectTimeAvgData.value.shift()
inflightData.value.shift()
} }
// 更新图表 // 更新图表
updateTpsChart() updateTpsChart()
updateRtChart() updateRtChart()
updateStatusChart() updateStatusChart()
updateLatencyChart()
updateInflightChart()
taskStatus.value = 'running' taskStatus.value = 'running'
} }
...@@ -338,12 +433,18 @@ function connectWs() { ...@@ -338,12 +433,18 @@ function connectWs() {
avgRtData.value = [] avgRtData.value = []
p50Data.value = [] p50Data.value = []
p90Data.value = [] p90Data.value = []
p95Data.value = []
p99Data.value = [] p99Data.value = []
latencyAvgData.value = []
connectTimeAvgData.value = []
inflightData.value = []
status2xx.value = 0 status2xx.value = 0
status4xx.value = 0 status4xx.value = 0
status5xx.value = 0 status5xx.value = 0
currentTps.value = 0 currentTps.value = 0
currentAvgRt.value = 0 currentAvgRt.value = 0
currentPeakTps.value = 0
currentInflight.value = 0
totalRequests.value = 0 totalRequests.value = 0
errorRate.value = 0 errorRate.value = 0
taskStatus.value = '' taskStatus.value = ''
...@@ -411,6 +512,8 @@ onUnmounted(() => { ...@@ -411,6 +512,8 @@ onUnmounted(() => {
tpsChart?.dispose() tpsChart?.dispose()
rtChart?.dispose() rtChart?.dispose()
statusChart?.dispose() statusChart?.dispose()
latencyChart?.dispose()
inflightChart?.dispose()
}) })
</script> </script>
......
...@@ -65,7 +65,7 @@ ...@@ -65,7 +65,7 @@
<el-descriptions-item label="实际时长">{{ report.summary.durationActual != null ? report.summary.durationActual.toFixed(1) + 's' : '-' }}</el-descriptions-item> <el-descriptions-item label="实际时长">{{ report.summary.durationActual != null ? report.summary.durationActual.toFixed(1) + 's' : '-' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<!-- 核心指标卡片 --> <!-- 核心指标卡片(第一行:请求统计) -->
<el-row :gutter="12" class="metrics-row"> <el-row :gutter="12" class="metrics-row">
<el-col :span="6"> <el-col :span="6">
<el-card shadow="never" class="metric-card"> <el-card shadow="never" class="metric-card">
...@@ -95,6 +95,7 @@ ...@@ -95,6 +95,7 @@
</el-col> </el-col>
</el-row> </el-row>
<!-- 第二行:吞吐 + 响应时间 -->
<el-row :gutter="12" class="metrics-row"> <el-row :gutter="12" class="metrics-row">
<el-col :span="6"> <el-col :span="6">
<el-card shadow="never" class="metric-card"> <el-card shadow="never" class="metric-card">
...@@ -102,12 +103,72 @@ ...@@ -102,12 +103,72 @@
<div class="metric-label">实际 TPS</div> <div class="metric-label">实际 TPS</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.metrics.peakTps.toFixed(1) }}</div>
<div class="metric-label">峰值 TPS</div>
</el-card>
</el-col>
<el-col :span="6"> <el-col :span="6">
<el-card shadow="never" class="metric-card"> <el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.metrics.avgResponseTime.toFixed(1) }}ms</div> <div class="metric-value">{{ report.metrics.avgResponseTime.toFixed(1) }}ms</div>
<div class="metric-label">平均响应时间</div> <div class="metric-label">平均响应时间</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: rtPerfColor(report.metrics.p95ResponseTime) }">
{{ report.metrics.p95ResponseTime.toFixed(1) }}ms
</div>
<div class="metric-label">P95 响应时间</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.metrics.stdDev.toFixed(1) }}ms</div>
<div class="metric-label">响应时间标准差</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: apdexColor(report.metrics.apdex) }">
{{ report.metrics.apdex.toFixed(2) }}
</div>
<div class="metric-label">Apdex 满意度</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.metrics.latencyAvg.toFixed(1) }}ms</div>
<div class="metric-label">平均首字节延迟</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.metrics.connectTimeAvg.toFixed(1) }}ms</div>
<div class="metric-label">平均连接时间</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.metrics.sentKbPerSec.toFixed(1) }}</div>
<div class="metric-label">发送速率 (KB/s)</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.metrics.receivedKbPerSec.toFixed(1) }}</div>
<div class="metric-label">接收速率 (KB/s)</div>
</el-card>
</el-col>
<el-col :span="6"> <el-col :span="6">
<el-card shadow="never" class="metric-card"> <el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.metrics.minResponseTime.toFixed(1) }}ms</div> <div class="metric-value">{{ report.metrics.minResponseTime.toFixed(1) }}ms</div>
...@@ -123,13 +184,28 @@ ...@@ -123,13 +184,28 @@
</el-row> </el-row>
<!-- 响应时间分位数 --> <!-- 响应时间分位数 -->
<el-row :gutter="12">
<el-col :span="12">
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="chart-card">
<template #header><span>响应时间摘要</span></template> <template #header><span>响应时间分位数</span></template>
<el-table :data="quantileRows" size="small"> <el-table :data="quantileRows" size="small">
<el-table-column prop="label" label="分位数" width="120" /> <el-table-column prop="label" label="分位数" width="120" />
<el-table-column prop="value" label="值" /> <el-table-column prop="value" label="值" />
</el-table> </el-table>
</el-card> </el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>错误分类统计</span></template>
<el-table :data="errorTypeRows" size="small" v-if="hasErrorTypes">
<el-table-column prop="label" label="错误类型" />
<el-table-column prop="count" label="数量" width="80" />
<el-table-column prop="percent" label="占比" width="100" />
</el-table>
<el-empty v-else description="无错误分类数据" :image-size="60" />
</el-card>
</el-col>
</el-row>
<!-- 状态码分布表 --> <!-- 状态码分布表 -->
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="chart-card">
...@@ -141,6 +217,17 @@ ...@@ -141,6 +217,17 @@
</el-table> </el-table>
</el-card> </el-card>
<!-- 网络延迟分解卡(发送/接收总字节) -->
<el-card shadow="never" class="chart-card">
<template #header><span>网络传输统计</span></template>
<el-descriptions :column="4" border size="small">
<el-descriptions-item label="总发送字节">{{ formatBytes(report.metrics.totalSentBytes) }}</el-descriptions-item>
<el-descriptions-item label="总接收字节">{{ formatBytes(report.metrics.totalReceivedBytes) }}</el-descriptions-item>
<el-descriptions-item label="平均发送速率">{{ report.metrics.sentKbPerSec.toFixed(1) }} KB/s</el-descriptions-item>
<el-descriptions-item label="平均接收速率">{{ report.metrics.receivedKbPerSec.toFixed(1) }} KB/s</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 快照趋势图 --> <!-- 快照趋势图 -->
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="12"> <el-col :span="12">
...@@ -151,11 +238,27 @@ ...@@ -151,11 +238,27 @@
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="chart-card">
<template #header><span>响应时间趋势</span></template> <template #header><span>响应时间趋势(含 P95)</span></template>
<div ref="rtChartRef" class="chart" /> <div ref="rtChartRef" class="chart" />
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<!-- 延迟分解趋势图(首字节延迟 + 连接时间) -->
<el-row :gutter="12">
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>延迟分解趋势(首字节 / 连接)</span></template>
<div ref="latencyChartRef" class="chart" />
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>带宽趋势(发送 / 接收)</span></template>
<div ref="bandwidthChartRef" class="chart" />
</el-card>
</el-col>
</el-row>
</template> </template>
<el-empty v-else description="暂无报告数据(任务尚未执行)" /> <el-empty v-else description="暂无报告数据(任务尚未执行)" />
...@@ -215,20 +318,71 @@ const report = ref<PerformanceReportResponse | null>(null) ...@@ -215,20 +318,71 @@ const report = ref<PerformanceReportResponse | null>(null)
const tpsChartRef = ref<HTMLElement>() const tpsChartRef = ref<HTMLElement>()
const rtChartRef = ref<HTMLElement>() const rtChartRef = ref<HTMLElement>()
const latencyChartRef = ref<HTMLElement>()
const bandwidthChartRef = ref<HTMLElement>()
let tpsChart: echarts.ECharts | null = null let tpsChart: echarts.ECharts | null = null
let rtChart: echarts.ECharts | null = null let rtChart: echarts.ECharts | null = null
let latencyChart: echarts.ECharts | null = null
let bandwidthChart: echarts.ECharts | null = null
/** 分位数表格行 */ /** 分位数表格行(含 P95) */
const quantileRows = computed(() => { const quantileRows = computed(() => {
if (!report.value) return [] if (!report.value) return []
const m = report.value.metrics const m = report.value.metrics
return [ return [
{ label: 'P50(中位数)', value: m.p50ResponseTime.toFixed(1) + ' ms' }, { label: 'P50(中位数)', value: m.p50ResponseTime.toFixed(1) + ' ms' },
{ label: 'P90', value: m.p90ResponseTime.toFixed(1) + ' ms' }, { label: 'P90', value: m.p90ResponseTime.toFixed(1) + ' ms' },
{ label: 'P95', value: m.p95ResponseTime.toFixed(1) + ' ms' },
{ label: 'P99', value: m.p99ResponseTime.toFixed(1) + ' ms' }, { label: 'P99', value: m.p99ResponseTime.toFixed(1) + ' ms' },
] ]
}) })
/** 是否存在错误分类数据 */
const hasErrorTypes = computed(() => {
if (!report.value) return false
const m = report.value.metrics
return (m.errorTypeTimeout || m.errorTypeConnectError || m.errorTypeClientError ||
m.errorTypeHttp4xx || m.errorTypeHttp5xx || m.errorTypeAssertion) > 0
})
/** 错误分类表格行 */
const errorTypeRows = computed(() => {
if (!report.value) return []
const m = report.value.metrics
const total = (report.value.metrics.failCount || 0) || 1
const pct = (n: number) => ((n / total) * 100).toFixed(1) + '%'
return [
{ label: '请求超时', count: m.errorTypeTimeout, percent: pct(m.errorTypeTimeout) },
{ label: '连接错误', count: m.errorTypeConnectError, percent: pct(m.errorTypeConnectError) },
{ label: '客户端错误(本地)', count: m.errorTypeClientError, percent: pct(m.errorTypeClientError) },
{ label: 'HTTP 4xx', count: m.errorTypeHttp4xx, percent: pct(m.errorTypeHttp4xx) },
{ label: 'HTTP 5xx', count: m.errorTypeHttp5xx, percent: pct(m.errorTypeHttp5xx) },
{ label: '断言失败', count: m.errorTypeAssertion, percent: pct(m.errorTypeAssertion) },
]
})
/** 响应时间性能着色:<300ms 绿 / <1000ms 黄 / 其余红(贴合市场 P95 参考线) */
function rtPerfColor(p95: number) {
if (p95 >= 1000) return '#f56c6c'
if (p95 >= 300) return '#e6a23c'
return '#67c23a'
}
/** Apdex 着色:≥0.94 绿 / ≥0.85 黄 / <0.85 红 */
function apdexColor(v: number) {
if (v >= 0.94) return '#67c23a'
if (v >= 0.85) return '#e6a23c'
return '#f56c6c'
}
/** 字节数人性化显示(KB/MB) */
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'
}
/** 状态码表格行 */ /** 状态码表格行 */
const statusRows = computed(() => { const statusRows = computed(() => {
if (!report.value) return [] if (!report.value) return []
...@@ -281,6 +435,12 @@ function initCharts() { ...@@ -281,6 +435,12 @@ function initCharts() {
if (rtChartRef.value && !rtChart) { if (rtChartRef.value && !rtChart) {
rtChart = echarts.init(rtChartRef.value) rtChart = echarts.init(rtChartRef.value)
} }
if (latencyChartRef.value && !latencyChart) {
latencyChart = echarts.init(latencyChartRef.value)
}
if (bandwidthChartRef.value && !bandwidthChart) {
bandwidthChart = echarts.init(bandwidthChartRef.value)
}
} }
/** 更新图表 */ /** 更新图表 */
...@@ -292,7 +452,12 @@ function updateCharts() { ...@@ -292,7 +452,12 @@ function updateCharts() {
const avg = snapshots.map((s) => s.avgResponseTime) const avg = snapshots.map((s) => s.avgResponseTime)
const p50 = snapshots.map((s) => s.p50ResponseTime) const p50 = snapshots.map((s) => s.p50ResponseTime)
const p90 = snapshots.map((s) => s.p90ResponseTime) const p90 = snapshots.map((s) => s.p90ResponseTime)
const p95 = snapshots.map((s) => s.p95ResponseTime)
const p99 = snapshots.map((s) => s.p99ResponseTime) const p99 = snapshots.map((s) => s.p99ResponseTime)
const latencyAvg = snapshots.map((s) => s.latencyAvg)
const connectTimeAvg = snapshots.map((s) => s.connectTimeAvg)
const sentBytes = snapshots.map((s) => s.sentBytes)
const receivedBytes = snapshots.map((s) => s.receivedBytes)
if (tpsChart) { if (tpsChart) {
const option: EChartsOption = { const option: EChartsOption = {
...@@ -319,19 +484,50 @@ function updateCharts() { ...@@ -319,19 +484,50 @@ function updateCharts() {
if (rtChart) { if (rtChart) {
const option: EChartsOption = { const option: EChartsOption = {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['平均', 'P50', 'P90', 'P99'], top: 0, itemWidth: 12, itemHeight: 8 }, legend: { data: ['平均', 'P50', 'P90', 'P95', 'P99'], top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 }, grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: labels }, xAxis: { type: 'category', data: labels },
yAxis: { type: 'value', name: 'ms' }, yAxis: { type: 'value', name: 'ms' },
series: [ series: [
{ name: '平均', type: 'line', data: avg, smooth: true, showSymbol: false, lineStyle: { width: 1.5 } }, { name: '平均', type: 'line', data: avg, smooth: true, showSymbol: false, lineStyle: { width: 1.5 } },
{ name: 'P50', type: 'line', data: p50, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dashed' } }, { name: 'P50', type: 'line', data: p50, smooth: true, showSymbol: false, lineStyle: { width: 1.2, type: 'dashed' } },
{ name: 'P90', type: 'line', data: p90, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dashed' } }, { name: 'P90', type: 'line', data: p90, smooth: true, showSymbol: false, lineStyle: { width: 1.2, type: 'dashed' } },
{ name: 'P99', type: 'line', data: p99, smooth: true, showSymbol: false, lineStyle: { width: 1.5, type: 'dotted' } }, { name: 'P95', type: 'line', data: p95, smooth: true, showSymbol: false, lineStyle: { width: 2, color: '#e6a23c' } },
{ name: 'P99', type: 'line', data: p99, smooth: true, showSymbol: false, lineStyle: { width: 1.2, type: 'dotted' } },
], ],
} }
rtChart.setOption(option, true) rtChart.setOption(option, true)
} }
if (latencyChart) {
const option: EChartsOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['首字节延迟', '连接时间'], top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value', name: 'ms' },
series: [
{ name: '首字节延迟', type: 'line', data: latencyAvg, smooth: true, showSymbol: false, areaStyle: { opacity: 0.1 } },
{ name: '连接时间', type: 'line', data: connectTimeAvg, smooth: true, showSymbol: false, lineStyle: { type: 'dashed' } },
],
}
latencyChart.setOption(option, true)
}
if (bandwidthChart) {
const option: EChartsOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['发送 KB/s', '接收 KB/s'], top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value', name: 'KB/s' },
series: [
{ name: '发送 KB/s', type: 'line', data: sentBytes.map(b => +(b / 1024).toFixed(1)), smooth: true, showSymbol: false, lineStyle: { color: '#409eff' } },
{ name: '接收 KB/s', type: 'line', data: receivedBytes.map(b => +(b / 1024).toFixed(1)), smooth: true, showSymbol: false, lineStyle: { color: '#67c23a' } },
],
}
bandwidthChart.setOption(option, true)
}
} }
/** 模式标签 */ /** 模式标签 */
...@@ -364,6 +560,8 @@ onMounted(() => { ...@@ -364,6 +560,8 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
tpsChart?.dispose() tpsChart?.dispose()
rtChart?.dispose() rtChart?.dispose()
latencyChart?.dispose()
bandwidthChart?.dispose()
}) })
</script> </script>
......
...@@ -138,7 +138,7 @@ ...@@ -138,7 +138,7 @@
<div v-if="selectedPreset.description"><strong>描述:</strong>{{ selectedPreset.description }}</div> <div v-if="selectedPreset.description"><strong>描述:</strong>{{ selectedPreset.description }}</div>
<div> <div>
<strong>认证:</strong> <strong>认证:</strong>
{{ selectedPreset.authRequired ? '需要登录' : '无需登录' }}<template v-if="selectedPreset.authRequired">{{ accountKeyLabel(selectedPreset.accountKey) }}</template> {{ selectedPreset.authRequired ? '需要登录' : '无需登录' }}<template v-if="selectedPreset.authRequired">{{ selectedPreset.credentialName || accountKeyLabel(selectedPreset.accountKey) }}</template>
· 签名:{{ selectedPreset.signRequest ? '需要' : '不需要' }} · 签名:{{ selectedPreset.signRequest ? '需要' : '不需要' }}
</div> </div>
<el-button link type="primary" size="small" class="preset-summary-clear" @click="onPresetChange('')"> <el-button link type="primary" size="small" class="preset-summary-clear" @click="onPresetChange('')">
...@@ -593,6 +593,8 @@ async function onPresetChange(presetId: string) { ...@@ -593,6 +593,8 @@ async function onPresetChange(presetId: string) {
form.authRequired = preset.authRequired form.authRequired = preset.authRequired
form.signRequest = preset.signRequest form.signRequest = preset.signRequest
form.accountKey = preset.accountKey || 'superadmin' form.accountKey = preset.accountKey || 'superadmin'
form.loginUsername = preset.loginUsername || ''
form.loginPassword = preset.loginPassword || ''
form.presetId = preset.id form.presetId = preset.id
} catch (e: any) { } catch (e: any) {
ElMessage.error('获取预设详情失败: ' + (e.message || '')) ElMessage.error('获取预设详情失败: ' + (e.message || ''))
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论