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

feat(perf): 目标机 Java 进程瞬时 CPU 采集 + 多实例服务级聚合与 MySQL 趋势

- target_resource_monitor.py: 新增 /proc/<pid>/stat 两次采样(JSTAT1/JSTAT2,间隔 0.2s)
  以 utime+stime tick 差值计算 top 口径瞬时 CPU%(100% = 单核,可超 100%),
  按目标机核数上限截断,采样缺失时回退 ps %cpu(修复压测中途重启 JVM 被
  ps 生命周期平均严重低估的问题,实测匹配 top 的 300%+ 数值)
- _JAVA_PROCESS_KEYWORDS 新增 ubains-gateway/auth/modules-system/message-scheduling/mqtt
- performance_executor.py: 多 PID 服务级聚合(按采样点求和、不补 0)+ pidCount/pids
  非破坏性字段 + mysql.series 趋势序列;schema 透传无需改动
- 前端: types/performance.ts 类型扩展;ReportPanel.vue 实例 PID 列 + 两张 MySQL 趋势图
- 测试: test_target_resource_monitor.py(JSTAT 瞬时 CPU 15 例)+ test_target_resource_summary.py(聚合 7 例)全通过
- 文档: 新增 PRD 需求文档 + 计划执行文档(Java 服务多实例与 MySQL 容器趋势监控)

已验证并部署 5.60:py_compile/pytest/前端真实页面冒烟通过,5.44 实测 8 核 clk=100 解析正确
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 426c01cd
......@@ -7,8 +7,6 @@
> **状态**: ✅ 遗留 HANDOFF 文档更新已提交 `df34c1c8`;✅ Vue placeholder 引号转义修复已提交 `11e1dd85`;✅ 两 commit 均已推送 origin/platform-auto-test
> **会话窗口(上一窗口)**: AI 分析报告资源使用分析四维度增强(commit `c73ffd58`,已推送 + 部署 5.60 + 用户 2026-08-28 真实验收)
---
## ⚡ 最新会话更新(2026-08-31)— 清理提交:HANDOFF 文档入库 + Vue placeholder 转义修复 ✅
### A. 会话背景
......@@ -327,6 +325,64 @@ fix(perf-report): 补全报告导出缺失模块与图表 + 接口名URL去前
| 2 | **接口名去前缀验证** | 重新执行一次压测任务 → 请求详情察看结果树接口名不再带 `https://IP` 前缀(需新执行才生效) |
| 3 | 遗留未提交文件 | `backend/data/test_platform.db` 工作区变更(按约定不提交);`backend/scripts/probe_44_202.py``frontend/public/` 临时文件确认是否清理 |
| 4 | FR-5 图片加载提示(P2 可选) | 计划文档已列:PDF 导出加载期间显示 loading 提示,未实现,非必须 |
## ⚡ 最新会话更新(2026-08-31)— Java 服务多实例 + MySQL 容器趋势监控(已部署 5.60)
### A. 会话背景
目标机 SSH 监控此前已能采集系统资源、Java 进程和 `umysql` 容器指标。本批补齐五个基础 Java 服务关键字,修正同一服务多 PID 时的报告口径(服务级聚合),并将 MySQL 采样从「仅展示最后一次卡片」扩展为可回放趋势,便于关联 TPS、延迟与数据库负载。
### B. 需求与计划文档(新建)
| 文档 | 路径 |
|------|------|
| PRD 需求文档 | `Docs/PRD/性能测试/需求文档/_PRD_Java服务多实例与MySQL容器趋势监控.md` |
| 计划执行文档 | `Docs/PRD/性能测试/需求文档/_PRD_Java服务多实例与MySQL容器趋势监控_计划执行.md` |
### C. 改动清单 ✅(4 个 Phase 全部完成)
**后端**`backend/app/executors/`):
- `target_resource_monitor.py``_JAVA_PROCESS_KEYWORDS` 新增五个服务关键字:`ubains-gateway``ubains-auth``ubains-modules-system``ubains-meeting-message-scheduling``ubains-meeting-mqtt`,均位于泛化 `ubains-meeting` 之前(首个匹配生效,避免被吞并)。
- `performance_executor.py:_build_target_resource_summary()`
1. **多 PID 服务级聚合**:按采样时间点(elapsed)为边界、按 `processName` 分组,组内 PID 的 CPU/内存/RSS **求和**;每个服务每个时间点仅一个趋势点。统计 avg/max 按服务级时间点计算(不再把多个 PID 观测行直接混合)。某采样点服务不可见时**不补 0**(可见样本口径,避免 SSH/进程缺失误判为零资源)。
2. **非破坏性字段**`javaProcessStats` / `javaProcessSeries` 增加 `pidCount``pids`(去重排序),既有 `processName/cpuPercent/memPercent/rssMb/avg*/max*` 字段名保持不变。
3. **MySQL 趋势序列**:从每个可用采样构建 `mysql.series`(elapsed、连接数、活跃线程、慢查询、缓冲池命中率、累计连接/查询、收发流量);累计值保留原始值不做平均;最新值卡片字段全部保留。
- `schemas/performance.py` 无需改动(`target_resource_summary` 本就 `Dict[str, Any]` 透传)。
**前端**
- `src/types/performance.ts``JavaProcessStats`/`JavaProcessSeries` 增加 `pidCount?`/`pids?`;新增 `MysqlSeriesPoint``TargetResourceSummary.mysql` 增加可选 `series?`,全部可选兼容旧报告。
- `src/views/performance/ReportPanel.vue` — Java 表格新增「实例 PID」列(数量 + hover 列表);新增两张 MySQL 趋势图:
- **MySQL 状态趋势**:连接数 / 活跃线程 / 慢查询 / 缓冲池命中率(双 Y 轴)
- **MySQL 累计与流量趋势**:累计连接 / 查询 / 收发流量(双 Y 轴)
-`mysql.available && series 有点` 时渲染;复用既有 ECharts init/resize/dispose 生命周期;旧报告/无数据安全降级。
### D. 验证结果 ✅
| 验证项 | 结果 |
|--------|------|
| `python -m py_compile`(两个后端文件) | ✅ 通过 |
| 新增 `backend/tests/test_target_resource_summary.py` | ✅ 7 passed(双 PID 求和单点 / pidCount·pids / 服务级统计 / mysql.series 字段与单调 elapsed / 无数据降级) |
| 既有 `backend/tests/test_performance_executor.py` | ✅ 25 passed(无回归) |
| `npm run build`(前端类型检查 + Vite 打包) | ✅ 通过 |
### E. 部署 5.60 ✅
1. 上传后端:`performance_executor.py``target_resource_monitor.py``/data/third_party/plat-auto-test/backend/app/executors/`
2. 前端:`npm run build` 产物全量重传(先清远端 `dist/assets``index.html``scp -r`,避免旧 hash 残留)→ `/data/third_party/plat-auto-test/frontend/dist/`
3. `docker compose restart app`,健康检查 `GET /health` = 200,容器 `Up (healthy)`
4. 容器内 `import` 验证通过、关键字列表正确;报告页新 bundle 生效(远端 assets 含 `ReportPanel-WeDbcjj2.js`,含「MySQL 状态趋势」「实例 PID」新代码)
5. 性能 API 冒烟:`/api/performance/tasks` = 200(6 个任务),`/api/performance/executions` = 200
### F. 本次会话待办(交接给下一窗口)
| # | 任务 | 优先级 | 说明 |
|---|------|--------|------|
| 1 | **git commit + push** | P0 | 本批全部改动未提交(2 后端 + 2 前端 + 2 文档 + 1 测试),可用 `/GitCommit` |
| 2 | **端到端实测新口径** | P1 | 5.60 真实执行任务,验证报告页:多 PID 服务显示 PID 数/列表、趋势不重复、MySQL 两张趋势图正常渲染;Java CPU 图 Y 轴动态上限 |
| 3 | 遗留未提交文件 | — | `backend/data/test_platform.db`(按约定不提交)、`backend/scripts/probe_44_202.py``frontend/public/`(临时文件,未纳入本批改动) |
---
---
......
# PRD - Java 服务多实例与 MySQL 容器趋势监控
> **文档版本**:v1.0
> **创建日期**:2026-08-31
> **模块类型**:性能测试 - 目标机资源监控
> **优先级**:P1
## 一、背景与目标
目标机 SSH 监控已能采集系统资源、Java 进程和 `umysql` 容器指标。本次补齐五个基础 Java 服务,并修正同一服务多 PID 时的报告口径;同时将 MySQL 采样从“仅展示最后一次卡片”扩展为可回放趋势,便于关联 TPS、延迟与数据库负载。
目标:
1. 监控 `ubains-gateway``ubains-auth``ubains-modules-system``ubains-meeting-message-scheduling``ubains-meeting-mqtt`
2. 保留实时 PID 明细,同时在报告中按服务和采样时刻聚合多实例。
3. 保存 MySQL 关键指标趋势,旧报告和采集失败时安全降级。
## 二、功能需求
### 2.1 Java 服务识别
命令行匹配采用关键字子串方式,具体服务关键字必须位于泛化的 `ubains-meeting` 之前。每个 PID 的实时快照仍包含 `processName``pid``cpuPercent``memPercent``rssMb`
### 2.2 多实例服务级聚合
- 以采样时间点(`elapsed`)为边界,按 `processName` 聚合该时刻可见的全部 PID。
- CPU、内存百分比和 RSS 均为服务内 PID 求和;每个服务每个时间点最多一个趋势点。
- 平均值按服务级时间点计算,最大值为服务级时间点最大值,不将 PID 观测行直接混合。
- `javaProcessStats``javaProcessSeries` 保持原字段兼容,并增加 `pidCount``pids`(去重 PID 列表)。
- 服务在某个采样暂时不可见时不补 0;仅统计实际可见样本,避免 SSH/进程缺失被误判为零资源。
### 2.3 MySQL 趋势
`targetResourceSummary.mysql` 保留最新采样的既有卡片字段,并增加可选 `series`。趋势点至少支持:`elapsed`、当前连接数、活跃线程、慢查询、缓冲池命中率、累计连接/查询及收发流量。累计计数保留原始值,不计算平均值。仅存在可用 MySQL 采样时生成序列。
报告页展示连接数、活跃线程、慢查询、缓冲池命中率趋势;累计指标可在同图或第二组趋势中展示。无序列、旧报告或 MySQL 不可用时保留卡片或隐藏趋势,不影响报告加载。
## 三、数据与兼容性
不新增数据库列、不改变 API 路径;`target_resource_summary` 继续以 JSON 字典透传。新增字段均为可选,历史报告没有 `pidCount``pids``mysql.series` 时前端使用现有字段正常渲染。
## 四、验收标准
1. 五个关键字均可从 Java 命令行识别,且不被 `ubains-meeting` 吞并。
2. 两个相同服务 PID 在同一采样点只产生一个服务趋势点,CPU/RSS 为求和,统计平均/最大符合服务级口径。
3. 多个 MySQL 样本产生按 elapsed 排序的趋势点,最新卡片字段保持不变。
4. 报告页趋势正常显示;旧格式、无 Java、MySQL 采集失败均不报错。
5. 后端编译与测试、前端构建通过。
## 五、非功能与降级
采集命令、SSH 重连和压测执行流程不变。进程或容器短暂不可见只丢弃该时刻对应指标,不伪造零值;整体无可用样本时沿用现有隐藏目标机资源区块逻辑。敏感凭据不落库。
......@@ -2699,30 +2699,48 @@ class PerformanceExecutor:
entry["mysqlThreadsRunning"] = mysql.get("threadsRunning")
series.append(entry)
# ====== Java 进程汇总 ======
# 收集所有样本中出现的 Java 进程按 processName 组织
java_process_map: dict = {} # processName -> {cpu[], mem[], rss[], data[{elapsed,...}]}
# ====== Java 进程汇总(服务级聚合) ======
# 多实例口径:同一服务可能启动多个 PID,实时快照保留 PID 级明细;
# 报告汇总按“每个采样时间点 → 按 processName 聚合该时刻全部 PID”
# 生成服务级趋势点:CPU/内存/RSS 为组内 PID 求和,一个服务一个时间点仅一个点,
# 统计均值/最大按服务级时间点计算(不再把多个 PID 的观测行直接混合)。
# 某采样点该服务不可见时不补 0(可见样本口径,避免把 SSH/进程缺失误判为零资源)。
java_process_map: dict = {} # processName -> {cpu[], mem[], rss[], data[{elapsed,...}], pids:set}
for s in usable:
ts = s.get("timestamp", 0.0)
elapsed = round(ts - first_ts, 1) if first_ts else 0.0
# 先聚合当前采样点内同一服务的所有 PID
per_sample: dict = {} # processName -> {cpu, mem, rss, pids:set}
for proc in s.get("javaProcesses", []):
pname = proc["processName"]
entry = per_sample.setdefault(pname, {"cpu": 0.0, "mem": 0.0, "rss": 0.0, "pids": set()})
entry["cpu"] += proc.get("cpuPercent", 0.0)
entry["mem"] += proc.get("memPercent", 0.0)
entry["rss"] += proc.get("rssMb", 0.0)
pid = proc.get("pid")
if pid is not None:
entry["pids"].add(pid)
for pname, entry in per_sample.items():
if pname not in java_process_map:
java_process_map[pname] = {
"cpu": [],
"mem": [],
"rss": [],
"data": [],
"pids": set(),
}
java_process_map[pname]["cpu"].append(proc.get("cpuPercent", 0.0))
java_process_map[pname]["mem"].append(proc.get("memPercent", 0.0))
java_process_map[pname]["rss"].append(proc.get("rssMb", 0.0))
java_process_map[pname]["cpu"].append(round(entry["cpu"], 1))
java_process_map[pname]["mem"].append(round(entry["mem"], 1))
java_process_map[pname]["rss"].append(round(entry["rss"], 1))
java_process_map[pname]["data"].append({
"elapsed": elapsed,
"cpuPercent": proc.get("cpuPercent", 0.0),
"memPercent": proc.get("memPercent", 0.0),
"rssMb": proc.get("rssMb", 0.0),
"cpuPercent": round(entry["cpu"], 1),
"memPercent": round(entry["mem"], 1),
"rssMb": round(entry["rss"], 1),
"pidCount": len(entry["pids"]),
"pids": sorted(entry["pids"]),
})
java_process_map[pname]["pids"].update(entry["pids"])
java_process_stats = []
java_process_series = []
......@@ -2730,8 +2748,11 @@ class PerformanceExecutor:
cpu_v = vals["cpu"]
mem_v = vals["mem"]
rss_v = vals["rss"]
pid_list = sorted(vals["pids"])
java_process_stats.append({
"processName": pname,
"pidCount": len(pid_list),
"pids": pid_list,
"avgCpuPercent": round(sum(cpu_v) / len(cpu_v), 1) if cpu_v else 0.0,
"maxCpuPercent": round(max(cpu_v), 1) if cpu_v else 0.0,
"avgMemPercent": round(sum(mem_v) / len(mem_v), 1) if mem_v else 0.0,
......@@ -2741,6 +2762,8 @@ class PerformanceExecutor:
})
java_process_series.append({
"processName": pname,
"pidCount": len(pid_list),
"pids": pid_list,
"data": vals["data"],
})
......@@ -2759,6 +2782,30 @@ class PerformanceExecutor:
"series": series,
}
if mysql_latest:
# 构建 MySQL 趋势序列:每个可用采样的 mysql 数据各生成一个趋势点。
# 累计指标(connections/questions/queries/bytes/慢查询)保留原始值,不计算平均。
mysql_series = []
for s in usable:
mysql = s.get("mysql", {})
if not mysql.get("available"):
continue
ts = s.get("timestamp", 0.0)
elapsed = round(ts - first_ts, 1) if first_ts else 0.0
mysql_series.append({
"elapsed": elapsed,
"threadsConnected": mysql.get("threadsConnected"),
"threadsRunning": mysql.get("threadsRunning"),
"maxUsedConnections": mysql.get("maxUsedConnections"),
"maxConnections": mysql.get("maxConnections"),
"connections": mysql.get("connections"),
"questions": mysql.get("questions"),
"queries": mysql.get("queries"),
"slowQueries": mysql.get("slowQueries"),
"bytesReceivedMb": mysql.get("bytesReceivedMb"),
"bytesSentMb": mysql.get("bytesSentMb"),
"bufferPoolHitRate": mysql.get("bufferPoolHitRate"),
"uptime": mysql.get("uptime"),
})
result["mysql"] = {
"available": True,
"container": mysql_latest.get("container", "umysql"),
......@@ -2775,6 +2822,7 @@ class PerformanceExecutor:
"bytesSentMb": mysql_latest.get("bytesSentMb"),
"bufferPoolHitRate": mysql_latest.get("bufferPoolHitRate"),
"uptime": mysql_latest.get("uptime"),
"series": mysql_series,
}
if java_process_stats:
result["javaProcessStats"] = java_process_stats
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_target_resource_monitor.py
模块描述:测试目标机资源监控的 Java 进程瞬时 CPU 解析(JSTAT tick 差值口径)
覆盖:JSTAT 瞬时 CPU 计算、ps %cpu 回退、核数上限截断、_parse_jstat_meta、
段落边界/关键字/comm 过滤鲁棒性
背景:ps %cpu 是进程自启动以来的平均占用,压测中途刚重启的 JVM 会严重低估
瞬时 CPU(top 显示 385% 而 ps 仅 1-3%)。修复后 _parse_java_processes 优先用
---JSTAT1---/---JSTAT2--- 段(/proc/<pid>/stat 的 utime+stime tick 差值,间隔
_STAT_WINDOW=0.2s)计算 top 口径瞬时 CPU%:100% = 单核满载,多线程可超 100%
作者:czj
创建日期:2026-08-31
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.executors.target_resource_monitor import TargetResourceMonitor # noqa: E402
def _build_output(
java_lines=None,
stats1=None,
stats2=None,
cores="8",
clk="100",
mysql_tail=True,
java_marker=True,
stat_marker=True,
):
"""按 _SSH_CMD 实际输出结构拼装命令行文本(JAVA/JSTAT/MySQL 各段)。"""
lines = [
"cpu 1000 2000 3000 4000 5000 6000 7000 8000",
"---MEM---",
"MemTotal: 16000000 kB",
"MemAvailable: 8000000 kB",
"---LOAD---",
"1.23 0.89 0.67 3/456 12345",
]
if java_marker:
lines.append("---JAVA---")
lines.extend(java_lines or [])
if stat_marker:
lines.append("---JSTAT1---")
if cores is not None:
lines.append(f"cores={cores}")
if clk is not None:
lines.append(f"clk={clk}")
for pid, ticks in (stats1 or {}).items():
lines.append(f"{pid} {ticks}")
lines.append("---JSTAT2---")
for pid, ticks in (stats2 or {}).items():
lines.append(f"{pid} {ticks}")
if mysql_tail:
lines.append("---MYSQL---")
lines.append("Uptime\t3600")
return "\n".join(lines) + "\n"
def _java_line(cpu, mem, rss_kb, pid, comm="java", args="-jar ubains-meeting-api.jar"):
"""ps -eo %cpu=,%mem=,rss=,pid=,comm=,args= 输出行"""
return f"{cpu} {mem} {rss_kb} {pid} {comm} {args}"
def _monitor() -> TargetResourceMonitor:
return TargetResourceMonitor(host="unused-host") # __init__ 不建连接
class TestJstatInstantCpu:
"""JSTAT tick 差值 → top 口径瞬时 CPU%"""
def test_instant_cpu_from_tick_diff(self):
out = _build_output(
java_lines=[_java_line(1.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 1060},
)
procs = _monitor()._parse_java_processes(out)
p = procs[0]
# delta=60 ticks → 60/clk(100)/0.2s*100 = 300.0(8 核可超 100%)
assert p["cpuPercent"] == 300.0
assert p["processName"] == "ubains-meeting-api"
assert p["pid"] == 101
assert p["memPercent"] == 10.0
assert p["rssMb"] == 488.3 # 500000KB / 1024
def test_multiple_pids_independent(self):
out = _build_output(
java_lines=[
_java_line(1.0, 10.0, 500000, 101, args="-jar ubains-meeting-api.jar"),
_java_line(1.0, 5.0, 300000, 102, args="-jar ubains-meeting-inner-api.jar"),
],
stats1={101: 1000, 102: 500},
stats2={101: 1060, 102: 540},
)
procs = {p["pid"]: p for p in _monitor()._parse_java_processes(out)}
assert procs[101]["cpuPercent"] == 300.0 # delta 60
assert procs[102]["cpuPercent"] == 200.0 # delta 40
assert procs[102]["processName"] == "ubains-meeting-inner-api"
def test_fallback_to_ps_when_no_jstat(self):
out = _build_output(
java_lines=[_java_line(12.5, 10.0, 500000, 101)],
stats1={},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 12.5
def test_fallback_when_one_snapshot_missing(self):
"""JSTAT2 缺失(进程退出/解析失败)→ 回退 ps %cpu"""
out = _build_output(
java_lines=[_java_line(3.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 3.0
def test_cap_at_cores_times_100(self):
out = _build_output(
java_lines=[_java_line(1.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 3000}, # delta=2000 → 5000%,被 8 核截断
cores="8",
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 800.0
def test_no_cap_without_cores(self):
"""未解析出核数(cores=None)→ 不截断,容忍瞬时高值"""
out = _build_output(
java_lines=[_java_line(1.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 1060},
cores=None,
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 300.0
def test_zero_or_negative_diff_falls_back_to_ps(self):
out = _build_output(
java_lines=[_java_line(2.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 1000}, # tick 未推进
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 2.0
def test_rounds_to_one_decimal(self):
"""delta=61 → 305.0;delta=1 → 5.0(需为整数 tick 才精确)"""
out = _build_output(
java_lines=[_java_line(1.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 1061},
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 305.0
class TestJstatMeta:
"""_parse_jstat_meta 解析核数/CLK_TCK"""
def test_parse_cores_and_clk(self):
out = _build_output(stats1={}, stats2={})
cores, clk = _monitor()._parse_jstat_meta(out)
assert cores == 8.0
assert clk == 100.0
def test_missing_meta_defaults(self):
out = _build_output(stats1={}, stats2={}, cores=None, clk=None)
cores, clk = _monitor()._parse_jstat_meta(out)
assert cores is None
assert clk == 100.0
def test_custom_cores_and_clk(self):
out = _build_output(stats1={}, stats2={}, cores="16", clk="250")
cores, clk = _monitor()._parse_jstat_meta(out)
assert cores == 16.0
assert clk == 250.0
class TestSectionRobustness:
"""段落边界 / 关键字 / comm 过滤"""
def test_mysql_tail_does_not_feed_java(self):
out = _build_output(
java_lines=[_java_line(5.0, 10.0, 500000, 101)],
stats1={},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert len(procs) == 1
def test_keyword_and_comm_filter(self):
out = _build_output(
java_lines=[
_java_line(5.0, 10.0, 500000, 101, args="-jar ubains-meeting-api.jar"),
_java_line(6.0, 11.0, 600000, 102, args="-jar some-other-app.jar"),
_java_line(7.0, 12.0, 700000, 103, comm="nginx",
args="nginx -c /etc/nginx/nginx.conf"),
],
stats1={},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert [p["pid"] for p in procs] == [101]
def test_generic_keyword_after_specific(self):
"""ubains-gateway 具体关键字优先于 ubains-meeting 泛化兜底"""
out = _build_output(
java_lines=[_java_line(5.0, 10.0, 500000, 101,
args="-jar ubains-gateway.jar --spring.profiles=meeting")],
stats1={},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["processName"] == "ubains-gateway"
def test_empty_output(self):
assert _monitor()._parse_java_processes("") == []
assert _monitor()._parse_java_processes("some random text\nno markers\n") == []
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_target_resource_summary.py
模块描述:测试目标机资源汇总的多 PID 服务级聚合与 MySQL 趋势序列
覆盖指标:多实例求和口径、服务级统计、pidCount/pids 字段、mysql.series 生成、旧格式兼容
作者:czj
创建日期:2026-08-31
"""
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.executors.performance_executor import PerformanceExecutor # noqa: E402
def _make_executor_with_samples(samples):
"""绕过 __init__(避免拉起线程/依赖)构造执行器,注入目标机采样序列。"""
executor = PerformanceExecutor.__new__(PerformanceExecutor)
executor._target_resource_samples = list(samples)
executor._target_resource_samples_lock = __import__("threading").Lock()
return executor
def _mock_executor():
"""构造带两个可用采样点(间隔 2s)的执行器:
- t0:ubains-meeting-api 两个 PID(CPU 30+40,rss 500+600),ubains-auth 单 PID(CPU 15)
- t2:ubains-meeting-api 仅 PID101 可见(CPU 45,rss 520),MySQL 不可用
t0/t2 均采集 MySQL(连接/查询为累计原始值)。
"""
t0 = time.time()
t1 = t0 + 2.0
base_mysql = {
"available": True,
"container": "umysql",
"threadsConnected": 5,
"threadsRunning": 2,
"maxUsedConnections": 5,
"maxConnections": 151,
"connections": 100,
"questions": 1000,
"queries": 500,
"slowQueries": 3,
"bytesReceivedMb": 10.0,
"bytesSentMb": 20.0,
"bufferPoolHitRate": 99.5,
"uptime": 3600,
}
s0 = {
"timestamp": t0,
"available": True,
"cpuPercent": 50.0,
"memPercent": 60.0,
"loadAvg1": 1.0,
"javaProcesses": [
{"processName": "ubains-meeting-api", "pid": 101,
"cpuPercent": 30.0, "memPercent": 10.0, "rssMb": 500.0},
{"processName": "ubains-meeting-api", "pid": 102,
"cpuPercent": 40.0, "memPercent": 12.0, "rssMb": 600.0},
{"processName": "ubains-auth", "pid": 201,
"cpuPercent": 15.0, "memPercent": 5.0, "rssMb": 300.0},
],
"mysql": dict(base_mysql, threadsConnected=5, connections=100, questions=1000),
}
s1 = {
"timestamp": t1,
"available": True,
"cpuPercent": 40.0,
"memPercent": 55.0,
"loadAvg1": 0.8,
"javaProcesses": [
{"processName": "ubains-meeting-api", "pid": 101,
"cpuPercent": 45.0, "memPercent": 9.0, "rssMb": 520.0},
{"processName": "ubains-auth", "pid": 201,
"cpuPercent": 20.0, "memPercent": 6.0, "rssMb": 310.0},
],
"mysql": dict(base_mysql, threadsConnected=8, connections=120, questions=1500),
}
return _make_executor_with_samples([s0, s1])
class TestJavaMultiPidAggregation:
"""同服务多 PID 按采样时间点聚合成服务级单点(求和口径)"""
def test_two_pid_sums_to_one_point(self):
summary = _mock_executor()._build_target_resource_summary()
api_series = next(s for s in summary["javaProcessSeries"]
if s["processName"] == "ubains-meeting-api")
# 两个采样点 → 两个服务级点(而非 3 个 PID 观测行)
assert len(api_series["data"]) == 2
# 第一个点:PID101(30) + PID102(40) = 70,rss 500+600=1100
p0 = api_series["data"][0]
assert p0["cpuPercent"] == 70.0
assert p0["rssMb"] == 1100.0
# 第二个点:仅 PID101 可见 → 45(可见样本口径,不补 0)
p1 = api_series["data"][1]
assert p1["cpuPercent"] == 45.0
assert p1["pidCount"] == 1
assert p1["pids"] == [101]
def test_pid_count_and_list(self):
summary = _mock_executor()._build_target_resource_summary()
api_stats = next(s for s in summary["javaProcessStats"]
if s["processName"] == "ubains-meeting-api")
assert api_stats["pidCount"] == 2
assert api_stats["pids"] == [101, 102]
def test_stats_service_level_averaging(self):
summary = _mock_executor()._build_target_resource_summary()
api_stats = next(s for s in summary["javaProcessStats"]
if s["processName"] == "ubains-meeting-api")
# 服务级时间点复数 [70, 45] → avg=(70+45)/2=57.5, max=70
assert api_stats["avgCpuPercent"] == 57.5
assert api_stats["maxCpuPercent"] == 70.0
# 单 PID 服务不受影响:auth 两个点 [15, 20] → avg 17.5
auth_stats = next(s for s in summary["javaProcessStats"]
if s["processName"] == "ubains-auth")
assert auth_stats["avgCpuPercent"] == 17.5
assert auth_stats["pidCount"] == 1
assert auth_stats["pids"] == [201]
class TestMysqlSeries:
"""MySQL 趋势序列生成与最新卡片字段保留"""
def test_series_points_and_fields(self):
summary = _mock_executor()._build_target_resource_summary()
mysql = summary["mysql"]
series = mysql["series"]
assert len(series) == 2
assert series[0]["elapsed"] == 0.0
assert series[1]["elapsed"] == 2.0
# 累计值保留原始值(不做平均)
assert series[0]["connections"] == 100
assert series[1]["connections"] == 120
assert series[0]["questions"] == 1000
assert series[1]["questions"] == 1500
# 即时量随采样变化
assert series[0]["threadsConnected"] == 5
assert series[1]["threadsConnected"] == 8
# 最新卡片字段保持不变
assert mysql["available"] is True
assert mysql["container"] == "umysql"
assert mysql["threadsConnected"] == 8
assert mysql["connections"] == 120
class TestCompatFallback:
"""旧格式/无数据场景兼容"""
def test_no_samples_returns_none(self):
summary = _make_executor_with_samples([])._build_target_resource_summary()
assert summary is None
def test_unavailable_only_returns_none(self):
summary = _make_executor_with_samples([
{"timestamp": time.time(), "available": False,
"javaProcesses": [], "mysql": {"available": False}},
])._build_target_resource_summary()
assert summary is None
def test_no_mysql_sample_has_no_series(self):
s = {
"timestamp": time.time(),
"available": True,
"cpuPercent": 10.0,
"memPercent": 10.0,
"loadAvg1": 0.1,
"javaProcesses": [],
"mysql": {"available": False},
}
summary = _make_executor_with_samples([s])._build_target_resource_summary()
assert "mysql" not in summary # 顶部字段不生成 → 前端沿用降级卡片逻辑
......@@ -156,9 +156,13 @@ export interface JavaProcessSample {
rssMb: number
}
/** 目标机 Java 进程统计汇总 */
/** 目标机 Java 进程统计汇总(服务级聚合:CPU/内存/RSS 为同服务全部 PID 求和) */
export interface JavaProcessStats {
processName: string
/** 服务实例(PID)数量 */
pidCount?: number
/** 服务实例 PID 列表(去重) */
pids?: number[]
avgCpuPercent: number
maxCpuPercent: number
avgMemPercent: number
......@@ -167,15 +171,26 @@ export interface JavaProcessStats {
maxRssMb: number
}
/** 目标机 Java 进程服务级趋势点(CPU/内存/RSS 为同服务全部 PID 求和) */
export interface JavaProcessSeriesPoint {
elapsed: number
cpuPercent: number
memPercent: number
rssMb: number
/** 该时间点可见实例数 */
pidCount?: number
/** 该时间点可见实例 PID 列表 */
pids?: number[]
}
/** 目标机 Java 进程趋势序列 */
export interface JavaProcessSeries {
processName: string
data: Array<{
elapsed: number
cpuPercent: number
memPercent: number
rssMb: number
}>
/** 服务实例(PID)数量 */
pidCount?: number
/** 服务实例 PID 列表(去重) */
pids?: number[]
data: JavaProcessSeriesPoint[]
}
/** 目标机资源监控单次采样数据 */
......@@ -221,6 +236,23 @@ export interface TargetResourceSeriesPoint {
loadAvg1: number
}
/** 目标机 MySQL 指标趋势点(累计值保留原始计数,不计算平均) */
export interface MysqlSeriesPoint {
elapsed: number
threadsConnected?: number
threadsRunning?: number
maxUsedConnections?: number
maxConnections?: number
connections?: number
questions?: number
queries?: number
slowQueries?: number
bytesReceivedMb?: number
bytesSentMb?: number
bufferPoolHitRate?: number
uptime?: number
}
/** 目标机资源监控汇总 */
export interface TargetResourceSummary {
host: string
......@@ -255,6 +287,8 @@ export interface TargetResourceSummary {
queries: number
bytesReceivedMb: number
bytesSentMb: number
/** MySQL 指标趋势序列(可选,旧报告无此字段) */
series?: MysqlSeriesPoint[]
} | null
}
......
......@@ -579,6 +579,11 @@
</template>
<el-table :data="report.targetResourceSummary.javaProcessStats" size="small" stripe border>
<el-table-column prop="processName" label="服务 Process" min-width="220" show-overflow-tooltip />
<el-table-column label="实例 PID" width="150" show-overflow-tooltip>
<template #default="{ row }">
{{ row.pidCount ?? (row.pids?.length || 0) }} {{ row.pids?.length ? `(${row.pids.join(', ')})` : '' }}
</template>
</el-table-column>
<el-table-column label="CPU 平均 Avg" width="120">
<template #default="{ row }">
<span :style="{ color: row.avgCpuPercent > 80 ? '#f56c6c' : '#409eff', fontWeight: 600 }">
......@@ -709,6 +714,25 @@
</el-card>
</el-col>
</el-row>
<!-- MySQL 指标趋势图(仅当存在趋势序列时展示,旧报告无 series 时自动隐藏) -->
<el-row
v-if="report.targetResourceSummary.mysql.series && report.targetResourceSummary.mysql.series.length"
:gutter="12"
>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>MySQL 状态趋势 MySQL Status(连接 / 活跃线程 / 慢查询 / 命中率)</span></template>
<div ref="mysqlStatusChartRef" class="chart" />
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>MySQL 累计与流量趋势 MySQL Cumulative(连接 / 查询 / 流量)</span></template>
<div ref="mysqlCumulativeChartRef" class="chart" />
</el-card>
</el-col>
</el-row>
</template>
<!-- 事务处理时间明细(仅事务任务) -->
......@@ -958,6 +982,8 @@ function onTabChange(tab: string) {
networkChart?.resize()
targetResourceChart?.resize()
javaProcessChart?.resize()
mysqlStatusChart?.resize()
mysqlCumulativeChart?.resize()
waterfallChart?.resize()
})
}
......@@ -974,6 +1000,10 @@ const networkChartRef = ref<HTMLElement>()
const targetResourceChartRef = ref<HTMLElement>()
const javaProcessChartRef = ref<HTMLElement>()
// MySQL 趋势图引用
const mysqlStatusChartRef = ref<HTMLElement>()
const mysqlCumulativeChartRef = ref<HTMLElement>()
// 瀑布图引用
const waterfallChartRef = ref<HTMLElement>()
......@@ -985,6 +1015,8 @@ let resourceChart: echarts.ECharts | null = null
let networkChart: echarts.ECharts | null = null
let targetResourceChart: echarts.ECharts | null = null
let javaProcessChart: echarts.ECharts | null = null
let mysqlStatusChart: echarts.ECharts | null = null
let mysqlCumulativeChart: echarts.ECharts | null = null
let waterfallChart: echarts.ECharts | null = null
/** 分位数表格行(含 P95) */
......@@ -1486,6 +1518,12 @@ function initCharts() {
if (javaProcessChartRef.value && !javaProcessChart) {
javaProcessChart = echarts.init(javaProcessChartRef.value)
}
if (mysqlStatusChartRef.value && !mysqlStatusChart) {
mysqlStatusChart = echarts.init(mysqlStatusChartRef.value)
}
if (mysqlCumulativeChartRef.value && !mysqlCumulativeChart) {
mysqlCumulativeChart = echarts.init(mysqlCumulativeChartRef.value)
}
if (waterfallChartRef.value && !waterfallChart) {
waterfallChart = echarts.init(waterfallChartRef.value)
}
......@@ -1659,11 +1697,62 @@ function updateCharts() {
legend: { data: jps.map((p: any) => p.processName), top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: jLabels, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', name: 'CPU %', max: 100 },
yAxis: {
type: 'value',
name: 'CPU %',
// 动态上限:多核 Java 进程可超 100%,按数据最大值取整到 10 的倍数 + 15% 余量,保底 100
max: (value: any) => Math.max(100, Math.ceil((value.max * 1.15) / 10) * 10),
},
series: jSeries,
}, true)
}
// MySQL 状态趋势图(连接 / 活跃线程 / 慢查询 / 缓冲池命中率)
const mts = report.value.targetResourceSummary?.mysql?.series
if (mts && mts.length > 0) {
const mLabels = mts.map((s: any) => `${s.elapsed.toFixed(0)}s`)
const mLegend = ['连接数 Threads Connected', '活跃线程 Threads Running', '慢查询 Slow Queries', '缓冲池命中率 Buffer Hit Rate %']
if (mysqlStatusChart) {
mysqlStatusChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: mLegend, top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: mLabels, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '连接 / 查询' },
{ type: 'value', name: '命中率 %', splitLine: { show: false }, max: 100 },
],
series: [
{ name: '连接数 Threads Connected', type: 'line', data: mts.map((s: any) => s.threadsConnected), smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#409eff' } },
{ name: '活跃线程 Threads Running', type: 'line', data: mts.map((s: any) => s.threadsRunning), smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#67c23a' } },
{ name: '慢查询 Slow Queries', type: 'line', data: mts.map((s: any) => s.slowQueries), smooth: true, showSymbol: false, lineStyle: { width: 1.2, type: 'dashed', color: '#e6a23c' } },
{ name: '缓冲池命中率 Buffer Hit Rate %', type: 'line', data: mts.map((s: any) => s.bufferPoolHitRate), smooth: true, showSymbol: false, yAxisIndex: 1, lineStyle: { width: 1.5, color: '#f56c6c' } },
],
}, true)
}
// MySQL 累计与流量趋势图(累计连接 / 查询 / 收发流量,累计值保留原始计数)
const mLegend2 = ['累计连接 Connections', '累计查询 Queries', '接收 Received(MB)', '发送 Sent(MB)']
if (mysqlCumulativeChart) {
mysqlCumulativeChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: mLegend2, top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: mLabels, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '累计' },
{ type: 'value', name: 'MB', splitLine: { show: false } },
],
series: [
{ name: '累计连接 Connections', type: 'line', data: mts.map((s: any) => s.connections), smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#409eff' } },
{ name: '累计查询 Queries', type: 'line', data: mts.map((s: any) => s.queries), smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#67c23a' } },
{ name: '接收 Received(MB)', type: 'line', data: mts.map((s: any) => s.bytesReceivedMb), smooth: true, showSymbol: false, yAxisIndex: 1, lineStyle: { width: 1.5, type: 'dashed', color: '#e6a23c' } },
{ name: '发送 Sent(MB)', type: 'line', data: mts.map((s: any) => s.bytesSentMb), smooth: true, showSymbol: false, yAxisIndex: 1, lineStyle: { width: 1.5, type: 'dashed', color: '#f56c6c' } },
],
}, true)
}
}
// 瀑布图(事务步骤耗时)
if (waterfallChart && report.value.transactionSummary?.stepSummaries?.length) {
const steps = report.value.transactionSummary.stepSummaries
......@@ -1737,6 +1826,8 @@ onUnmounted(() => {
networkChart?.dispose()
targetResourceChart?.dispose()
javaProcessChart?.dispose()
mysqlStatusChart?.dispose()
mysqlCumulativeChart?.dispose()
waterfallChart?.dispose()
// 离开报告页时释放 AI 文档 ObjectURL,避免内存泄漏
downloadArtifactStore.close()
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论