This commit is contained in:
12600k-rog-d4
2026-08-13 22:49:50 +08:00
parent 5e296bfe19
commit 2aaaa16ae2
24 changed files with 1415 additions and 327 deletions

View File

@@ -5,127 +5,264 @@ import (
"fmt"
"os"
"strings"
"time"
)
// chartData 给 JS 用的数据
type chartData struct {
Labels []string `json:"labels"`
CPU []float64 `json:"cpu"`
Temp []float64 `json:"temp"`
Title string `json:"title"`
// metricSpec 单条曲线规格
type metricSpec struct {
Key string `json:"key"`
Label string `json:"label"`
Color string `json:"color"`
Unit string `json:"unit"`
}
// WriteChartHTML 根据采样数据生成折线图 HTML 文件,返回文件路径
func WriteChartHTML(ip string, result Result, reportDir string) (string, error) {
if len(result.Samples) == 0 {
return "", nil
}
// chartSpec 图表规格(左轴 / 右轴)
type chartSpec struct {
Left []metricSpec
Right []metricSpec
LeftPct bool
LeftTitle string
RightTitle string
}
cd := chartData{
Title: fmt.Sprintf("%s - %s 压测曲线 (%s)", ip, result.Type, result.Duration),
}
for _, s := range result.Samples {
cd.Labels = append(cd.Labels, s.Time)
cd.CPU = append(cd.CPU, s.CPU)
cd.Temp = append(cd.Temp, s.Temp)
}
// kvPair 指标明细
type kvPair struct {
Label string `json:"label"`
Value string `json:"value"`
}
dataJSON, err := json.Marshal(cd)
// metricSeries 单条曲线序列
type metricSeries struct {
Label string `json:"label"`
Color string `json:"color"`
Data []float64 `json:"data"`
Unit string `json:"unit"`
}
// chartPayload 图表数据
type chartPayload struct {
Labels []string `json:"labels"`
LeftSets []metricSeries `json:"left_sets"`
RightSets []metricSeries `json:"right_sets"`
LeftTitle string `json:"left_title"`
RightTitle string `json:"right_title"`
LeftPct bool `json:"left_pct"`
}
// testCard 单个测试卡片数据
type testCard struct {
Name string `json:"name"`
Label string `json:"label"`
Status string `json:"status"`
Duration string `json:"duration"`
Score float64 `json:"score"`
ScoreUnit string `json:"score_unit"`
ScoreHint string `json:"score_hint"`
Metrics []kvPair `json:"metrics"`
Output string `json:"output"`
Chart *chartPayload `json:"chart,omitempty"`
}
// reportHTMLData 报告整体数据
type reportHTMLData struct {
Title string `json:"title"`
IP string `json:"ip"`
Time string `json:"time"`
Duration string `json:"duration"`
Overall string `json:"overall"`
Summary string `json:"summary"`
SysInfo map[string]string `json:"sys_info"`
Cards []testCard `json:"cards"`
}
// WriteReportHTML 生成综合 HTML 报告(跑分卡片 + 多曲线图),返回文件路径
func WriteReportHTML(ip string, report *Report, reportDir string) (string, error) {
data := buildReportData(ip, report)
dataJSON, err := json.Marshal(data)
if err != nil {
return "", err
}
html := strings.Replace(chartHTMLTemplate, "__DATA__", string(dataJSON), 1)
html := strings.Replace(reportHTMLTemplate, "__DATA__", string(dataJSON), 1)
os.MkdirAll(reportDir, 0755)
filename := fmt.Sprintf("%s/chart-%s-%s.html", reportDir, result.Type, SanitizeFilename(ip))
if err := os.MkdirAll(reportDir, 0755); err != nil {
return "", err
}
filename := fmt.Sprintf("%s/report-%s.html", reportDir, SanitizeFilename(ip))
if err := os.WriteFile(filename, []byte(html), 0644); err != nil {
return "", err
}
return filename, nil
}
func buildReportData(ip string, report *Report) reportHTMLData {
data := reportHTMLData{
Title: fmt.Sprintf("%s · 硬件压力测试报告", ip),
IP: ip,
Time: report.StartTime.Format("2006-01-02 15:04:05"),
Duration: report.Duration.Round(time.Millisecond).String(),
Overall: Status(report),
Summary: report.Summary(),
SysInfo: report.SysInfo,
}
for _, res := range report.Results {
data.Cards = append(data.Cards, buildCard(res))
}
return data
}
func buildCard(res Result) testCard {
card := testCard{
Name: string(res.Type),
Label: testLabel(res.Type),
Status: res.Status,
Duration: res.Duration.Round(time.Millisecond).String(),
Score: res.Score,
ScoreUnit: ScoreUnit(res.Type),
ScoreHint: scoreHint(res.Type),
Output: res.Output,
}
for _, name := range sortedMetricNames(res.Metrics) {
card.Metrics = append(card.Metrics, kvPair{
Label: MetricLabel(name),
Value: fmt.Sprintf("%.1f%s", res.Metrics[name], MetricUnit(name)),
})
}
if len(res.Samples) > 0 {
if payload, ok := buildChart(res); ok {
card.Chart = &payload
}
}
return card
}
func buildChart(res Result) (chartPayload, bool) {
spec := chartSpecFor(res.Type)
if len(spec.Left) == 0 && len(spec.Right) == 0 {
return chartPayload{}, false
}
payload := chartPayload{
LeftTitle: spec.LeftTitle,
RightTitle: spec.RightTitle,
LeftPct: spec.LeftPct,
}
for _, s := range res.Samples {
payload.Labels = append(payload.Labels, s.Time)
}
hasData := func(key string) bool {
for _, s := range res.Samples {
if s.HasMetric(key) {
return true
}
}
return false
}
buildSeries := func(specs []metricSpec) []metricSeries {
var out []metricSeries
for _, m := range specs {
if !hasData(m.Key) {
continue
}
series := metricSeries{Label: m.Label, Color: m.Color, Unit: m.Unit}
for _, s := range res.Samples {
series.Data = append(series.Data, s.Values[m.Key])
}
out = append(out, series)
}
return out
}
payload.LeftSets = buildSeries(spec.Left)
payload.RightSets = buildSeries(spec.Right)
return payload, true
}
// testLabel 测试项中文名
func testLabel(t TestType) string {
switch t {
case TestCPU:
return "CPU 压力测试"
case TestMemory:
return "内存压力测试"
case TestDiskIO:
return "磁盘 IO 测试"
case TestMemNative:
return "内存精压测试"
case TestFull:
return "综合工况测试"
case MonitorTemp:
return "温度监控"
case MonitorDmesg:
return "内核日志检查"
default:
return string(t)
}
}
// scoreHint 跑分含义说明
func scoreHint(t TestType) string {
switch t {
case TestCPU:
return "每秒操作数bogo ops/s"
case TestMemory:
return "每秒操作数bogo ops/s"
case TestDiskIO:
return "读写带宽合计MB/s"
case TestFull:
return "综合每秒操作数bogo ops/s"
case TestMemNative:
return "内存带宽MB/s"
default:
return ""
}
}
// chartSpecFor 返回某类测试的曲线规格
func chartSpecFor(t TestType) chartSpec {
switch t {
case TestCPU:
return chartSpec{
Left: []metricSpec{{Key: "cpu", Label: "CPU 利用率", Color: "#f97316", Unit: "%"}},
Right: []metricSpec{{Key: "temp", Label: "温度", Color: "#ef4444", Unit: "°C"}},
LeftPct: true,
LeftTitle: "利用率 (%)",
RightTitle: "温度 (°C)",
}
case TestMemory:
return chartSpec{
Left: []metricSpec{{Key: "mem_used_pct", Label: "内存使用率", Color: "#8b5cf6", Unit: "%"}},
Right: []metricSpec{{Key: "temp", Label: "温度", Color: "#ef4444", Unit: "°C"}},
LeftPct: true,
LeftTitle: "使用率 (%)",
RightTitle: "温度 (°C)",
}
case TestDiskIO:
return chartSpec{
Left: []metricSpec{{Key: "iops_total", Label: "IOPS", Color: "#22c55e", Unit: ""}},
Right: []metricSpec{{Key: "bw_total", Label: "吞吐", Color: "#3b82f6", Unit: "MB/s"}},
LeftPct: false,
LeftTitle: "IOPS",
RightTitle: "吞吐 (MB/s)",
}
case TestFull:
return chartSpec{
Left: []metricSpec{
{Key: "cpu", Label: "CPU 利用率", Color: "#f97316", Unit: "%"},
{Key: "mem_used_pct", Label: "内存使用率", Color: "#8b5cf6", Unit: "%"},
},
Right: []metricSpec{{Key: "temp", Label: "温度", Color: "#ef4444", Unit: "°C"}},
LeftPct: true,
LeftTitle: "利用率 (%)",
RightTitle: "温度 (°C)",
}
default:
return chartSpec{}
}
}
// SanitizeFilename 清理 IP 中的特殊字符,用于文件名
func SanitizeFilename(ip string) string {
return strings.ReplaceAll(ip, ":", "_")
}
const chartHTMLTemplate = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>压测曲线</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
body { font-family: -apple-system, sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 960px; margin: 0 auto; background: #fff; padding: 30px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
h2 { margin: 0 0 20px; color: #333; }
canvas { max-height: 400px; }
.legend { display: flex; gap: 24px; margin-top: 16px; font-size: 13px; color: #666; }
.legend span { display: flex; align-items: center; gap: 6px; }
.legend .dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
</style>
</head>
<body>
<div class="container">
<h2 id="title"></h2>
<canvas id="chart"></canvas>
<div class="legend">
<span><span class="dot" style="background:#f97316"></span> CPU 使用率 (%)</span>
<span><span class="dot" style="background:#ef4444"></span> 温度 (°C)</span>
</div>
</div>
<script>
const data = __DATA__;
document.getElementById('title').textContent = data.title;
new Chart(document.getElementById('chart'), {
type: 'line',
data: {
labels: data.labels,
datasets: [
{
label: 'CPU 使用率 (%)',
data: data.cpu,
borderColor: '#f97316',
backgroundColor: 'rgba(249,115,22,0.08)',
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: '温度 (°C)',
data: data.temp,
borderColor: '#ef4444',
backgroundColor: 'rgba(239,68,68,0.05)',
fill: true,
tension: 0.3,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
interaction: { intersect: false, mode: 'index' },
plugins: { legend: { display: false } },
scales: {
y: {
type: 'linear',
position: 'left',
min: 0,
max: 100,
title: { display: true, text: 'CPU (%)' },
grid: { color: '#f0f0f0' }
},
y1: {
type: 'linear',
position: 'right',
title: { display: true, text: '温度 (°C)' },
grid: { drawOnChartArea: false }
}
}
}
});
</script>
</body>
</html>`