0813
This commit is contained in:
@@ -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>`
|
||||
|
||||
Reference in New Issue
Block a user