Files

280 lines
12 KiB
Python

"""生成 HTML 可视化报告"""
import json
import logging
import os
from . import config
logger = logging.getLogger(__name__)
def generate_report(report: dict) -> str:
"""从报告数据生成 HTML 报告文件,返回输出路径"""
html = build_html(report)
os.makedirs(config.REPORT_DIR, exist_ok=True)
output_path = os.path.join(config.REPORT_DIR, "report.html")
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
logger.info("报告已生成: %s", output_path)
return output_path
def _diff_color(difficulty: str) -> str:
"""难度对应的颜色"""
return {
"简单": "#22c55e",
"中等": "#f59e0b",
"耗时": "#ef4444",
"压力": "#8b5cf6",
}.get(difficulty, "#6b7280")
def _stats_rows(results: list) -> str:
"""构建详细数据表格行 HTML"""
rows = []
for r in results:
stats = r.get("statistics", {})
if not stats:
continue
rows.append(
f'<tr>'
f'<td><strong>{r["case_name"]}</strong></td>'
f'<td><span class="diff-badge" style="background:{_diff_color(r["difficulty"])}">{r["difficulty"]}</span></td>'
f'<td class="status-{r["status"]}" style="font-weight:bold">{r["status"]}</td>'
f'<td class="mono">{stats.get("mean_ms", "N/A")}</td>'
f'<td class="mono">{stats.get("std_ms", "N/A")}</td>'
f'<td class="mono">{stats.get("min_ms", "N/A")}</td>'
f'<td class="mono">{stats.get("max_ms", "N/A")}</td>'
f'<td class="mono">{stats.get("p95_ms", "N/A")}</td>'
f'</tr>'
)
return "\n".join(rows)
def _chart_data(results: list) -> tuple:
"""提取图表数据:(labels, means, p95s)"""
labels, means, p95s = [], [], []
for r in results:
stats = r.get("statistics", {})
if stats:
labels.append(r["case_name"])
means.append(stats.get("mean_ms", 0))
p95s.append(stats.get("p95_ms", 0))
return labels, means, p95s
def _performance_analysis(results: list) -> str:
"""性能分析摘要"""
_, means, _ = _chart_data(results)
if len(means) < 2:
return ""
best_idx = means.index(min(means))
worst_idx = means.index(max(means))
ratio = (means[worst_idx] / means[best_idx]) if means[best_idx] > 0 else "N/A"
avg = sum(means) / len(means)
return (
f'<div class="analysis-box">'
f'<h3>性能分析</h3>'
f'<ul>'
f'<li><strong>最快用例:</strong> {results[best_idx]["case_name"]} ({means[best_idx]:.2f}ms)</li>'
f'<li><strong>最慢用例:</strong> {results[worst_idx]["case_name"]} ({means[worst_idx]:.2f}ms)</li>'
f'<li><strong>最快/最慢比:</strong> {ratio}x</li>'
f'<li><strong>平均延迟:</strong> {avg:.2f}ms</li>'
f'</ul></div>'
)
def _concurrency_summary(results: list) -> str:
"""提取并发压力测试摘要(case_08),无则返回空串"""
for r in results:
raw = r.get("raw_data", {}) or {}
if r.get("case_id") == "case_08_concurrency" and raw:
def g(key, suffix=""):
val = raw.get(key)
return "N/A" if val is None else f"{val}{suffix}"
keep = raw.get("tps_keep_rate_pct", 0)
keep_color = "#22c55e" if keep >= 60 else ("#f59e0b" if keep > 0 else "#ef4444")
err = raw.get("error_rate_pct", 0)
err_color = "#22c55e" if err == 0 else "#ef4444"
return (
f'<div class="section">'
f'<h2>⚡ 并发压力测试摘要</h2>'
f'<div class="dashboard">'
f'<div class="stat-card"><div class="label">并发数</div><div class="value" style="color:#8b5cf6">{g("concurrency")}</div></div>'
f'<div class="stat-card"><div class="label">基准 TPS</div><div class="value" style="color:#6366f1">{g("baseline_tps")}</div></div>'
f'<div class="stat-card"><div class="label">并发 TPS</div><div class="value" style="color:#6366f1">{g("concurrent_tps")}</div></div>'
f'<div class="stat-card"><div class="label">TPS 保持率</div><div class="value" style="color:{keep_color}">{g("tps_keep_rate_pct", "%")}</div></div>'
f'<div class="stat-card"><div class="label">P95 延迟</div><div class="value" style="color:#ef4444">{g("e2e_p95_ms", "ms")}</div></div>'
f'<div class="stat-card"><div class="label">P99 延迟</div><div class="value" style="color:#ef4444">{g("e2e_p99_ms", "ms")}</div></div>'
f'<div class="stat-card"><div class="label">错误率</div><div class="value" style="color:{err_color}">{g("error_rate_pct", "%")}</div></div>'
f'</div></div>'
)
return ""
def build_html(report: dict) -> str:
"""构建 HTML 报告"""
summary = report.get("summary", {})
results = report.get("results", [])
env = report.get("environment", {})
config_ = report.get("config", {})
timestamp = report.get("timestamp", "")
run_id = report.get("run_id", "")
case_names, case_means, case_p95s = _chart_data(results)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大模型速度测试报告 - {run_id}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #f8fafc; color: #1e293b; line-height: 1.6;
}}
.container {{ max-width: 1400px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 12px; margin-bottom: 20px; }}
.header h1 {{ font-size: 28px; margin-bottom: 10px; }}
.header .meta {{ font-size: 14px; opacity: 0.9; }}
.dashboard {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px; }}
.stat-card {{ background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.stat-card .label {{ font-size: 12px; color: #64748b; text-transform: uppercase; }}
.stat-card .value {{ font-size: 28px; font-weight: bold; margin-top: 5px; }}
.section {{ background: white; border-radius: 10px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
.section h2 {{ font-size: 20px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #e2e8f0; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }}
th {{ background: #f1f5f9; font-weight: 600; font-size: 13px; text-transform: uppercase; }}
tr:hover {{ background: #f8fafc; }}
.mono {{ font-family: 'SF Mono', 'Fira Code', monospace; }}
.diff-badge {{ padding: 4px 10px; border-radius: 12px; color: white; font-size: 12px; font-weight: 600; }}
.status-passed {{ color: #22c55e; }}
.status-failed {{ color: #ef4444; }}
.chart-container {{ position: relative; height: 400px; margin: 20px 0; }}
.analysis-box {{ background: #f0fdf4; border-left: 4px solid #22c55e; padding: 15px; border-radius: 6px; }}
.analysis-box ul {{ padding-left: 20px; margin-top: 10px; }}
.analysis-box li {{ margin: 5px 0; }}
footer {{ text-align: center; padding: 20px; color: #64748b; font-size: 13px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>大模型速度测试报告</h1>
<div class="meta">
Run ID: {run_id} | 时间: {timestamp} | 用例: {config_.get('total_cases', 0)} | 重复: {config_.get('repeats', 0)}次
<br>模型: {env.get('model', 'N/A')} | 平台: {env.get('platform', 'N/A')}
</div>
</div>
<div class="dashboard">
<div class="stat-card"><div class="label">总用例数</div><div class="value">{summary.get('total_cases', 0)}</div></div>
<div class="stat-card"><div class="label">通过</div><div class="value" style="color:#22c55e">{summary.get('passed', 0)}</div></div>
<div class="stat-card"><div class="label">失败</div><div class="value" style="color:#ef4444">{summary.get('failed', 0)}</div></div>
<div class="stat-card"><div class="label">总耗时</div><div class="value">{summary.get('total_elapsed_seconds', 0):.2f}s</div></div>
</div>
{_concurrency_summary(results)}
{_performance_analysis(results)}
<div class="section">
<h2>性能对比柱状图</h2>
<div class="chart-container"><canvas id="barChart"></canvas></div>
</div>
<div class="section">
<h2>P95延迟对比</h2>
<div class="chart-container"><canvas id="p95Chart"></canvas></div>
</div>
<div class="section">
<h2>详细数据</h2>
<table>
<thead>
<tr>
<th>用例名称</th><th>难度</th><th>状态</th><th>平均延迟 (ms)</th>
<th>标准差</th><th>最小</th><th>最大</th><th>P95</th>
</tr>
</thead>
<tbody>
{_stats_rows(results)}
</tbody>
</table>
</div>
<div class="section">
<h2>环境信息</h2>
<table>
<tr><td>模型</td><td class="mono">{env.get('model', 'N/A')}</td></tr>
<tr><td>运行平台</td><td class="mono">{env.get('platform', 'N/A')}</td></tr>
<tr><td>运行ID</td><td class="mono">{run_id}</td></tr>
<tr><td>测试时间</td><td class="mono">{timestamp}</td></tr>
</table>
</div>
<footer>大模型速度测试助手 | 报告由 report.py 自动生成</footer>
</div>
<script>
const barCtx = document.getElementById('barChart').getContext('2d');
new Chart(barCtx, {{
type: 'bar',
data: {{
labels: {json.dumps(case_names, ensure_ascii=False)},
datasets: [{{
label: '平均延迟 (ms)',
data: {json.dumps(case_means)},
backgroundColor: 'rgba(102, 126, 234, 0.7)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 1
}}]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{
legend: {{ display: false }},
title: {{ display: true, text: '各用例平均延迟对比 (ms)', font: {{ size: 16 }} }}
}},
scales: {{ y: {{ beginAtZero: true, title: {{ display: true, text: '延迟 (ms)' }} }} }}
}}
}});
const p95Ctx = document.getElementById('p95Chart').getContext('2d');
new Chart(p95Ctx, {{
type: 'bar',
data: {{
labels: {json.dumps(case_names, ensure_ascii=False)},
datasets: [{{
label: 'P95延迟 (ms)',
data: {json.dumps(case_p95s)},
backgroundColor: 'rgba(239, 68, 68, 0.7)',
borderColor: 'rgba(239, 68, 68, 1)',
borderWidth: 1
}}]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{
legend: {{ display: false }},
title: {{ display: true, text: '各用例P95延迟 (ms)', font: {{ size: 16 }} }}
}},
scales: {{ y: {{ beginAtZero: true, title: {{ display: true, text: '延迟 (ms)' }} }} }}
}}
}});
</script>
</body>
</html>"""