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>`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -10,10 +11,11 @@ import (
|
||||
|
||||
// Metric 测试指标(定义测什么、用什么工具、怎么判结果)
|
||||
type Metric struct {
|
||||
Name string // 指标名称(cpu/memory/disk/memnative/full)
|
||||
Tool string // 依赖的工具(stress-ng/stressapptest)
|
||||
Enabled bool // 是否启用
|
||||
IsMonitor bool // 是否为监控指标(temp/dmesg,自动附加)
|
||||
Name string // 指标名称(cpu/memory/disk/memnative/full)
|
||||
Tool string // 依赖的工具(stress-ng/stressapptest/fio)
|
||||
Enabled bool // 是否启用
|
||||
IsMonitor bool // 是否为监控指标(temp/dmesg,自动附加)
|
||||
DisableReason string // 禁用时展示的原因
|
||||
}
|
||||
|
||||
// BuildMetrics 根据配置和可用工具,生成要执行的指标列表
|
||||
@@ -28,16 +30,36 @@ func BuildMetrics(types []TestType, tools ToolSet) []Metric {
|
||||
case TestMemory:
|
||||
m.Tool = "stress-ng"
|
||||
case TestDiskIO:
|
||||
m.Tool = "stress-ng"
|
||||
// disk 优先 fio;无 fio 可降级 stress-ng
|
||||
switch {
|
||||
case tools.Has("fio"):
|
||||
m.Tool = "fio"
|
||||
case tools.Has("stress-ng"):
|
||||
m.Tool = "stress-ng"
|
||||
default:
|
||||
m.Tool = "fio"
|
||||
}
|
||||
case TestMemNative:
|
||||
m.Tool = "stressapptest"
|
||||
case TestFull:
|
||||
m.Tool = "stress-ng"
|
||||
default:
|
||||
m.Enabled = false
|
||||
m.DisableReason = "未知测试类型"
|
||||
}
|
||||
if m.Tool != "" && !tools.Has(m.Tool) {
|
||||
m.Enabled = false // 工具不可用,禁用
|
||||
|
||||
// 根据工具可用性决定是否禁用,并记录原因
|
||||
switch t {
|
||||
case TestDiskIO:
|
||||
if !tools.Has("fio") && !tools.Has("stress-ng") {
|
||||
m.Enabled = false
|
||||
m.DisableReason = "缺少 fio 或 stress-ng"
|
||||
}
|
||||
default:
|
||||
if m.Tool != "" && !tools.Has(m.Tool) {
|
||||
m.Enabled = false
|
||||
m.DisableReason = fmt.Sprintf("缺少 %s", m.Tool)
|
||||
}
|
||||
}
|
||||
metrics = append(metrics, m)
|
||||
}
|
||||
@@ -61,6 +83,7 @@ type Config struct {
|
||||
DiskSizeMB int // 0=默认1024
|
||||
DiskDir string // 空=自动临时目录
|
||||
TempLogInt time.Duration // 0=10s
|
||||
TempLimit float64 // 温度上限(°C),0=不判定
|
||||
}
|
||||
|
||||
// DefaultConfig 默认配置
|
||||
@@ -70,5 +93,6 @@ func DefaultConfig() Config {
|
||||
Threads: 4,
|
||||
DiskSizeMB: 1024,
|
||||
TempLogInt: 10 * time.Second,
|
||||
TempLimit: 90,
|
||||
}
|
||||
}
|
||||
|
||||
126
auto-check/pkg/stress/report_html.go
Normal file
126
auto-check/pkg/stress/report_html.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package stress
|
||||
|
||||
const reportHTMLTemplate = `<!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>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#f1f5f9;color:#1e293b;padding:24px}
|
||||
.wrap{max-width:1100px;margin:0 auto}
|
||||
header{background:linear-gradient(135deg,#0f172a 0%,#1e3a5f 100%);color:#fff;border-radius:16px;padding:28px 32px;margin-bottom:24px;box-shadow:0 4px 20px rgba(15,23,42,.25)}
|
||||
header .title{font-size:22px;font-weight:700;letter-spacing:.5px}
|
||||
header .meta{display:flex;flex-wrap:wrap;gap:20px;margin-top:14px;font-size:13px;color:#cbd5e1}
|
||||
header .meta b{color:#fff;font-weight:600}
|
||||
.sysinfo{margin-top:14px;font-size:12px;color:#94a3b8;line-height:1.8}
|
||||
.overall-row{display:flex;align-items:center;gap:16px;margin-top:14px}
|
||||
.badge{display:inline-block;padding:5px 14px;border-radius:999px;font-size:13px;font-weight:700;color:#fff}
|
||||
.summary{font-size:14px;color:#e2e8f0}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(480px,1fr));gap:20px}
|
||||
.card{background:#fff;border-radius:14px;padding:22px;box-shadow:0 1px 4px rgba(15,23,42,.06);border-left:4px solid #cbd5e1}
|
||||
.card.pass{border-left-color:#16a34a}
|
||||
.card.fail{border-left-color:#dc2626}
|
||||
.card.error{border-left-color:#f59e0b}
|
||||
.card.skip{border-left-color:#9ca3af}
|
||||
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.card-title{font-size:16px;font-weight:700}
|
||||
.card-sub{font-size:12px;color:#64748b;margin-top:4px}
|
||||
.score-row{display:flex;align-items:baseline;gap:14px;margin:16px 0;padding:14px 16px;background:#f8fafc;border-radius:10px}
|
||||
.score-val{font-size:34px;font-weight:800;color:#0f172a;font-variant-numeric:tabular-nums}
|
||||
.score-unit{font-size:14px;font-weight:600;color:#334155}
|
||||
.score-hint{font-size:12px;color:#94a3b8;margin-top:2px}
|
||||
.metrics{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:14px}
|
||||
.metric{display:flex;flex-direction:column;gap:2px;padding:8px 12px;background:#f1f5f9;border-radius:8px;min-width:88px}
|
||||
.metric .m-label{font-size:11px;color:#64748b}
|
||||
.metric .m-value{font-size:15px;font-weight:700;color:#0f172a}
|
||||
.chart-box{position:relative;height:260px;margin-top:6px}
|
||||
details.output{margin-top:14px;border-top:1px dashed #e2e8f0;padding-top:10px}
|
||||
details.output summary{cursor:pointer;font-size:12px;color:#64748b;user-select:none}
|
||||
details.output pre{white-space:pre-wrap;word-break:break-all;font-size:11px;color:#475569;background:#f8fafc;border-radius:8px;padding:10px;margin-top:8px;max-height:240px;overflow:auto}
|
||||
.empty{grid-column:1/-1;text-align:center;color:#94a3b8;padding:40px;font-size:14px}
|
||||
@media(max-width:560px){.grid{grid-template-columns:1fr}.score-val{font-size:28px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="title" id="title"></div>
|
||||
<div class="meta">
|
||||
<span>设备 <b id="ip"></b></span>
|
||||
<span>测试时间 <b id="time"></b></span>
|
||||
<span>总耗时 <b id="duration"></b></span>
|
||||
</div>
|
||||
<div class="sysinfo" id="sysinfo"></div>
|
||||
<div class="overall-row">
|
||||
<span class="badge" id="overall"></span>
|
||||
<span class="summary" id="summary"></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="grid" id="cards"></div>
|
||||
</div>
|
||||
<script>
|
||||
var STATUS={pass:{t:'通过',c:'#16a34a'},fail:{t:'失败',c:'#dc2626'},skip:{t:'跳过',c:'#9ca3af'},error:{t:'错误',c:'#f59e0b'},untested:{t:'未测',c:'#94a3b8'}};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||
function fmt(v){if(v==null)return'-';if(typeof v==='number')return v.toLocaleString(undefined,{maximumFractionDigits:1});return String(v);}
|
||||
function rgba(hex,a){var h=hex.replace('#','');var r=parseInt(h.substring(0,2),16),g=parseInt(h.substring(2,4),16),b=parseInt(h.substring(4,6),16);return 'rgba('+r+','+g+','+b+','+a+')';}
|
||||
function seriesLabel(s){return s.unit?s.label+' ('+s.unit+')':s.label;}
|
||||
function buildCard(card,idx){
|
||||
var m=STATUS[card.status]||{t:card.status,c:'#64748b'};
|
||||
var el=document.createElement('div');
|
||||
el.className='card '+card.status;
|
||||
var h='<div class="card-head"><span class="card-title">'+esc(card.label)+'</span>'+
|
||||
'<span class="badge" style="background:'+m.c+'">'+m.t+'</span></div>'+
|
||||
'<div class="card-sub">'+esc(card.name)+' · 耗时 '+esc(card.duration)+'</div>';
|
||||
if(card.score>0){
|
||||
h+='<div class="score-row"><div class="score-val">'+fmt(card.score)+'</div>'+
|
||||
'<div><div class="score-unit">'+esc(card.score_unit)+'</div>'+
|
||||
'<div class="score-hint">'+esc(card.score_hint)+'</div></div></div>';
|
||||
}
|
||||
if(card.metrics&&card.metrics.length){
|
||||
h+='<div class="metrics">';
|
||||
card.metrics.forEach(function(x){h+='<div class="metric"><span class="m-label">'+esc(x.label)+'</span><span class="m-value">'+esc(x.value)+'</span></div>';});
|
||||
h+='</div>';
|
||||
}
|
||||
if(card.chart){h+='<div class="chart-box"><canvas id="c'+idx+'"></canvas></div>';}
|
||||
if(card.output&&card.status!=='pass'){
|
||||
h+='<details class="output"><summary>详细输出</summary><pre>'+esc(card.output)+'</pre></details>';
|
||||
}
|
||||
el.innerHTML=h;
|
||||
return el;
|
||||
}
|
||||
function renderChart(canvas,chart){
|
||||
var left=(chart.left_sets||[]).map(function(s){return{label:seriesLabel(s),data:s.data,borderColor:s.color,backgroundColor:rgba(s.color,.08),fill:true,tension:.3,yAxisID:'y'};});
|
||||
var right=(chart.right_sets||[]).map(function(s){return{label:seriesLabel(s),data:s.data,borderColor:s.color,backgroundColor:rgba(s.color,.05),fill:false,tension:.3,yAxisID:'y1'};});
|
||||
var scales={y:{type:'linear',position:'left',title:{display:true,text:chart.left_title},grid:{color:'#f1f5f9'}}};
|
||||
if(chart.left_pct){scales.y.min=0;scales.y.max=100;}
|
||||
scales.y1={type:'linear',position:'right',title:{display:true,text:chart.right_title},grid:{drawOnChartArea:false}};
|
||||
new Chart(canvas,{type:'line',data:{labels:chart.labels,datasets:left.concat(right)},
|
||||
options:{responsive:true,maintainAspectRatio:false,interaction:{intersect:false,mode:'index'},
|
||||
plugins:{legend:{display:true,position:'bottom',labels:{boxWidth:12,font:{size:11}}}},scales:scales}});
|
||||
}
|
||||
var data=__DATA__;
|
||||
document.getElementById('title').textContent=data.title;
|
||||
document.getElementById('ip').textContent=data.ip;
|
||||
document.getElementById('time').textContent=data.time;
|
||||
document.getElementById('duration').textContent=data.duration;
|
||||
document.getElementById('summary').textContent=data.summary;
|
||||
var si=data.sys_info||{},sp=[];
|
||||
if(si.cpu)sp.push('CPU: '+si.cpu);
|
||||
if(si.cores)sp.push('核心: '+si.cores);
|
||||
if(si.memory)sp.push('内存: '+si.memory);
|
||||
if(si.kernel)sp.push('内核: '+si.kernel);
|
||||
if(si.disk)sp.push('磁盘: '+si.disk);
|
||||
document.getElementById('sysinfo').textContent=sp.join(' · ');
|
||||
var om=STATUS[data.overall]||{t:data.overall,c:'#64748b'};
|
||||
var ob=document.getElementById('overall');ob.textContent=om.t;ob.style.background=om.c;
|
||||
var grid=document.getElementById('cards');
|
||||
data.cards.forEach(function(card,idx){
|
||||
grid.appendChild(buildCard(card,idx));
|
||||
if(card.chart){var cv=document.getElementById('c'+idx);if(cv)renderChart(cv,card.chart);}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -24,18 +25,20 @@ var Results = make(map[string]*Report)
|
||||
|
||||
// Runner 压力测试执行器
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
types []TestType
|
||||
client *ssh.Client
|
||||
tools ToolSet
|
||||
metrics []Metric
|
||||
cfg Config
|
||||
types []TestType
|
||||
client *ssh.Client
|
||||
tools ToolSet
|
||||
metrics []Metric
|
||||
installFailed []string // 自动安装仍失败的工具
|
||||
}
|
||||
|
||||
// NewRunner 创建执行器(检测工具 → 构建指标 → 生成脚本 → 执行)
|
||||
func NewRunner(cfg Config, types []TestType, client *ssh.Client) *Runner {
|
||||
tools := DetectTools(client)
|
||||
// NewRunner 创建执行器(检查/在线安装 → 构建指标 → 生成脚本 → 执行)
|
||||
func NewRunner(cfg Config, types []TestType, client *ssh.Client, autoInstall bool, sudoPwd string) *Runner {
|
||||
// 1. 检测工具,缺失时按发行版在线安装(可配置国内镜像源加速)
|
||||
tools, failed := EnsureTools(client, requiredTools, autoInstall, sudoPwd)
|
||||
metrics := BuildMetrics(types, tools)
|
||||
return &Runner{cfg: cfg, types: types, client: client, tools: tools, metrics: metrics}
|
||||
return &Runner{cfg: cfg, types: types, client: client, tools: tools, metrics: metrics, installFailed: failed}
|
||||
}
|
||||
|
||||
// Run 执行压力测试(生成脚本 → 远程执行 → 解析结果)
|
||||
@@ -49,8 +52,11 @@ func (r *Runner) Run(ip string) *Report {
|
||||
fmt.Printf("%s(%s) ", name, info.Version)
|
||||
}
|
||||
fmt.Println()
|
||||
if len(r.installFailed) > 0 {
|
||||
fmt.Printf(" [安装] 以下工具自动安装失败,相关测试将跳过: %v\n", r.installFailed)
|
||||
}
|
||||
|
||||
// 显示将执行的指标
|
||||
// 显示将执行的指标及被跳过的原因
|
||||
fmt.Printf(" 指标: ")
|
||||
for _, m := range r.metrics {
|
||||
if m.Enabled {
|
||||
@@ -58,6 +64,11 @@ func (r *Runner) Run(ip string) *Report {
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
for _, m := range r.metrics {
|
||||
if !m.Enabled && !m.IsMonitor {
|
||||
fmt.Printf(" [跳过] %s: %s\n", m.Name, m.DisableReason)
|
||||
}
|
||||
}
|
||||
|
||||
// 按需读取各测试脚本,拼装成完整脚本
|
||||
script := r.assembleScript()
|
||||
@@ -76,20 +87,19 @@ func (r *Runner) Run(ip string) *Report {
|
||||
r.parseResults(report, output)
|
||||
}
|
||||
|
||||
// 生成采样折线图
|
||||
for _, res := range report.Results {
|
||||
if len(res.Samples) > 0 {
|
||||
chartPath, err := WriteChartHTML(ip, res, "reports")
|
||||
if err != nil {
|
||||
fmt.Printf(" [图表] 生成失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" [图表] %s\n", chartPath)
|
||||
}
|
||||
report.EndTime = time.Now()
|
||||
report.Duration = report.EndTime.Sub(report.StartTime)
|
||||
|
||||
// 生成综合 HTML 报告(跑分卡片 + 曲线图)
|
||||
if len(report.Results) > 0 {
|
||||
reportPath, err := WriteReportHTML(ip, report, "reports")
|
||||
if err != nil {
|
||||
fmt.Printf(" [报告] 生成失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" [报告] %s\n", reportPath)
|
||||
}
|
||||
}
|
||||
|
||||
report.EndTime = time.Now()
|
||||
report.Duration = report.EndTime.Sub(report.StartTime)
|
||||
fmt.Printf("\n════════ [%s] 压力测试完成 ════════\n", ip)
|
||||
fmt.Println(report.ToText())
|
||||
return report
|
||||
@@ -103,7 +113,11 @@ func (r *Runner) parseResults(report *Report, output string) {
|
||||
var currentDuration string
|
||||
var currentOutput []string
|
||||
var currentSamples []Sample
|
||||
var currentScore float64
|
||||
var currentMetrics map[string]float64
|
||||
var sampleHeader []string
|
||||
inSamples := false
|
||||
inSysInfo := false
|
||||
|
||||
flush := func() {
|
||||
if currentTest == "" {
|
||||
@@ -118,6 +132,14 @@ func (r *Runner) parseResults(report *Report, output string) {
|
||||
case "error":
|
||||
status = StatusError
|
||||
}
|
||||
// 温度超限判定(仅对 temp 监控)
|
||||
if TestType(currentTest) == MonitorTemp && status == StatusPass && r.cfg.TempLimit > 0 {
|
||||
if maxT, ok := currentMetrics["max_temp"]; ok && maxT > r.cfg.TempLimit {
|
||||
status = StatusFail
|
||||
currentOutput = append(currentOutput,
|
||||
fmt.Sprintf("温度超限: 最高 %.1f°C > 阈值 %.1f°C", maxT, r.cfg.TempLimit))
|
||||
}
|
||||
}
|
||||
duration, _ := time.ParseDuration(currentDuration)
|
||||
report.AddResult(Result{
|
||||
Type: TestType(currentTest),
|
||||
@@ -125,12 +147,17 @@ func (r *Runner) parseResults(report *Report, output string) {
|
||||
Output: strings.Join(currentOutput, "\n"),
|
||||
Duration: duration,
|
||||
Samples: currentSamples,
|
||||
Score: currentScore,
|
||||
Metrics: currentMetrics,
|
||||
})
|
||||
currentTest = ""
|
||||
currentStatus = ""
|
||||
currentDuration = ""
|
||||
currentOutput = nil
|
||||
currentSamples = nil
|
||||
currentScore = 0
|
||||
currentMetrics = nil
|
||||
sampleHeader = nil
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
@@ -139,6 +166,7 @@ func (r *Runner) parseResults(report *Report, output string) {
|
||||
// 采样数据块
|
||||
if line == "===SAMPLES===" {
|
||||
inSamples = true
|
||||
sampleHeader = nil
|
||||
continue
|
||||
}
|
||||
if line == "===END_SAMPLES===" {
|
||||
@@ -147,14 +175,46 @@ func (r *Runner) parseResults(report *Report, output string) {
|
||||
}
|
||||
if inSamples {
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) >= 3 {
|
||||
cpu, _ := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
temp, _ := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
|
||||
currentSamples = append(currentSamples, Sample{
|
||||
Time: strings.TrimSpace(parts[0]),
|
||||
CPU: cpu,
|
||||
Temp: temp,
|
||||
})
|
||||
if sampleHeader == nil {
|
||||
// 首行为表头:time,指标1,指标2,...
|
||||
for _, p := range parts {
|
||||
sampleHeader = append(sampleHeader, strings.TrimSpace(p))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(parts) < 1 || strings.TrimSpace(parts[0]) == "" {
|
||||
continue
|
||||
}
|
||||
s := Sample{Time: strings.TrimSpace(parts[0]), Values: map[string]float64{}}
|
||||
for i := 1; i < len(parts) && i < len(sampleHeader); i++ {
|
||||
name := sampleHeader[i]
|
||||
if name == "" || name == "time" {
|
||||
continue
|
||||
}
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(parts[i]), 64); err == nil {
|
||||
s.Values[name] = v
|
||||
}
|
||||
}
|
||||
currentSamples = append(currentSamples, s)
|
||||
continue
|
||||
}
|
||||
|
||||
if line == "===SYSTEM_INFO===" {
|
||||
inSysInfo = true
|
||||
continue
|
||||
}
|
||||
if inSysInfo {
|
||||
if line == "" {
|
||||
inSysInfo = false
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(line, ":"); i > 0 {
|
||||
k := strings.TrimSpace(line[:i])
|
||||
v := strings.TrimSpace(line[i+1:])
|
||||
if report.SysInfo == nil {
|
||||
report.SysInfo = map[string]string{}
|
||||
}
|
||||
report.SysInfo[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -187,12 +247,24 @@ func (r *Runner) parseResults(report *Report, output string) {
|
||||
currentDuration = fmt.Sprintf("%dms", ms)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "output:") {
|
||||
currentOutput = append(currentOutput, strings.TrimPrefix(line, "output:"))
|
||||
if strings.HasPrefix(line, "score:") {
|
||||
currentScore, _ = strconv.ParseFloat(strings.TrimPrefix(line, "score:"), 64)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "samples:") || strings.HasPrefix(line, "max:") {
|
||||
currentOutput = append(currentOutput, line)
|
||||
if strings.HasPrefix(line, "metric:") {
|
||||
kv := strings.TrimPrefix(line, "metric:")
|
||||
if i := strings.Index(kv, "="); i > 0 {
|
||||
name := strings.TrimSpace(kv[:i])
|
||||
val, _ := strconv.ParseFloat(strings.TrimSpace(kv[i+1:]), 64)
|
||||
if currentMetrics == nil {
|
||||
currentMetrics = map[string]float64{}
|
||||
}
|
||||
currentMetrics[name] = val
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "output:") {
|
||||
currentOutput = append(currentOutput, strings.TrimPrefix(line, "output:"))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -202,13 +274,6 @@ func (r *Runner) parseResults(report *Report, output string) {
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
// 从系统信息输出解析主机信息
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "hostname:") {
|
||||
// 可扩展:存入 report 的系统信息字段
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assembleScript 根据 metrics 读取对应的独立脚本文件,拼装成完整脚本
|
||||
@@ -248,9 +313,24 @@ func (r *Runner) assembleScript() string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// 按"先单项、后综合"排序测试脚本
|
||||
ordered := make([]Metric, len(r.metrics))
|
||||
copy(ordered, r.metrics)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return testPriority(ordered[i]) < testPriority(ordered[j])
|
||||
})
|
||||
|
||||
// 逐个测试脚本
|
||||
for _, m := range r.metrics {
|
||||
if m.IsMonitor || !m.Enabled {
|
||||
for _, m := range ordered {
|
||||
if m.IsMonitor {
|
||||
continue
|
||||
}
|
||||
if !m.Enabled {
|
||||
// 缺少依赖时仍输出一个 skip 结果,让报告完整展示原因
|
||||
sb.WriteString(fmt.Sprintf("echo '===TEST:%s==='\n", m.Name))
|
||||
sb.WriteString("echo 'status:skip'\n")
|
||||
sb.WriteString(fmt.Sprintf("echo 'output:%s'\n", m.DisableReason))
|
||||
sb.WriteString("echo ''\n")
|
||||
continue
|
||||
}
|
||||
data := r.readScript(m.Name)
|
||||
@@ -286,6 +366,27 @@ func (r *Runner) readScript(name string) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
// testPriority 测试执行顺序:单项测试在前,综合测试(full)最后,监控项最后
|
||||
func testPriority(m Metric) int {
|
||||
if m.IsMonitor {
|
||||
return 1000
|
||||
}
|
||||
switch TestType(m.Name) {
|
||||
case TestCPU:
|
||||
return 0
|
||||
case TestMemory:
|
||||
return 1
|
||||
case TestDiskIO:
|
||||
return 2
|
||||
case TestMemNative:
|
||||
return 3
|
||||
case TestFull:
|
||||
return 100
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 业务入口(workflow 调用)
|
||||
// ============================
|
||||
@@ -293,6 +394,7 @@ func (r *Runner) readScript(name string) []byte {
|
||||
// TestDevice 对单台设备执行 SSH 登录 + 压力测试(业务入口)
|
||||
func TestDevice(ip string, cfg config.Config) *Report {
|
||||
sshCfg := sshclient.NewConfig(cfg.SSH.User, cfg.SSH.Password, cfg.SSH.KeyFile, cfg.SSH.Port, cfg.Scan.Timeout)
|
||||
sshCfg.SudoPwd = cfg.SSH.SudoPwd
|
||||
|
||||
conn, err := sshclient.Connect(ip, sshCfg)
|
||||
if err != nil {
|
||||
@@ -300,6 +402,19 @@ func TestDevice(ip string, cfg config.Config) *Report {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// fnOS 的 admin 用户 HOME 目录可能不存在,登录会输出
|
||||
// "Could not chdir to home directory" 横幅污染命令输出,先建好 HOME
|
||||
sshclient.EnsureHome(conn)
|
||||
|
||||
// 根据配置决定是否在线安装时使用国内镜像源(默认 false,先试系统自带源)
|
||||
SetMirror(cfg.Stress.UseMirror)
|
||||
|
||||
// sudo 密码:优先用专属 sudo_password;为空时退回 SSH 登录密码(飞牛 admin 同密码)
|
||||
sudoPwd := cfg.SSH.SudoPwd
|
||||
if sudoPwd == "" {
|
||||
sudoPwd = cfg.SSH.Password
|
||||
}
|
||||
|
||||
// SSH 登录成功后回填远端 MAC(发现阶段只做了 ping,暂无 MAC)
|
||||
if mac := queryRemoteMAC(conn); mac != "" {
|
||||
if dev, ok := discovery.Devices[ip]; ok {
|
||||
@@ -314,8 +429,9 @@ func TestDevice(ip string, cfg config.Config) *Report {
|
||||
Threads: cfg.Stress.Threads,
|
||||
DiskSizeMB: 1024,
|
||||
TempLogInt: 10 * time.Second,
|
||||
TempLimit: cfg.Stress.TempLimit,
|
||||
}
|
||||
return NewRunner(stressCfg, ParseTypes(cfg.Stress.Types), conn).Run(ip)
|
||||
return NewRunner(stressCfg, ParseTypes(cfg.Stress.Types), conn, cfg.Stress.AutoInstall, sudoPwd).Run(ip)
|
||||
}
|
||||
|
||||
// NewSSHFailReport 构造 SSH 连接失败报告
|
||||
@@ -365,6 +481,15 @@ func IsPassed(rpt *Report) bool {
|
||||
return rpt != nil && rpt.Failed == 0 && rpt.Errors == 0
|
||||
}
|
||||
|
||||
// IsSSHFail 报告是否为 SSH 连接失败(连不上,无任何有效测试项)
|
||||
func IsSSHFail(rpt *Report) bool {
|
||||
if rpt == nil || len(rpt.Results) != 1 {
|
||||
return false
|
||||
}
|
||||
r := rpt.Results[0]
|
||||
return r.Type == "ssh" && (r.Status == StatusFail || r.Status == StatusError)
|
||||
}
|
||||
|
||||
// Status 报告状态字符串
|
||||
func Status(rpt *Report) string {
|
||||
if rpt == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"auto-check/pkg/sshclient"
|
||||
@@ -8,10 +9,82 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// 国内镜像源
|
||||
// ============================
|
||||
|
||||
// useMirror 控制是否把远端包管理器切到国内镜像。默认关闭——先用系统自带源
|
||||
// 试装(飞牛/群晖等定制系统常常已预置可用源),失败或超时再开启清华镜像。
|
||||
var useMirror = false
|
||||
|
||||
// SetMirror 设置是否使用国内镜像源(由配置注入)
|
||||
func SetMirror(on bool) {
|
||||
useMirror = on
|
||||
}
|
||||
|
||||
// mirrorName 根据发行版返回镜像别名(用于日志)
|
||||
const mirrorLabel = "清华 TUNA 镜像"
|
||||
|
||||
// setupMirror 在安装前把对应包管理器的源切换为国内镜像(仅 apt 系需要改文件,
|
||||
// 其它包管理器通过参数/变量临时指向镜像)。失败不致命,仅打印警告并继续用原源。
|
||||
func setupMirror(client *ssh.Client, pm string, sudoPwd string) {
|
||||
if !useMirror {
|
||||
return
|
||||
}
|
||||
switch pm {
|
||||
case "apt-get":
|
||||
// 备份并替换为清华镜像(Debian/Ubuntu 通用,按 lsb_release 选版本)
|
||||
script := `set -e
|
||||
OS_ID=$(. /etc/os-release 2>/dev/null; echo "$ID")
|
||||
OS_VER=$(. /etc/os-release 2>/dev/null; echo "$VERSION_CODENAME")
|
||||
[ -z "$OS_VER" ] && OS_VER=$(. /etc/os-release 2>/dev/null; echo "$VERSION_ID")
|
||||
MIRROR="https://mirrors.tuna.tsinghua.edu.cn"
|
||||
if [ "$OS_ID" = "ubuntu" ]; then
|
||||
LIST="$MIRROR/ubuntu"
|
||||
elif [ "$OS_ID" = "debian" ]; then
|
||||
LIST="$MIRROR/debian"
|
||||
elif [ "$OS_ID" = "raspbian" ]; then
|
||||
LIST="$MIRROR/raspbian"
|
||||
else
|
||||
LIST="$MIRROR/debian"
|
||||
fi
|
||||
BAK=/etc/apt/sources.list.d/auto-check-mirror.bak
|
||||
if [ ! -f "$BAK" ]; then
|
||||
cp /etc/apt/sources.list "$BAK" 2>/dev/null || true
|
||||
cp -r /etc/apt/sources.list.d "$BAK.d" 2>/dev/null || true
|
||||
fi
|
||||
: > /etc/apt/sources.list
|
||||
echo "deb $LIST $OS_VER main contrib non-free" > /etc/apt/sources.list
|
||||
echo "deb $LIST $OS_VER-updates main contrib non-free" >> /etc/apt/sources.list
|
||||
echo "deb $LIST $OS_VER-backports main contrib non-free" >> /etc/apt/sources.list
|
||||
echo "deb $LIST-security $OS_VER-security main contrib non-free" >> /etc/apt/sources.list
|
||||
echo OK`
|
||||
out, err := runPrivileged(client, "sh -c "+shellQuote(script), sudoPwd)
|
||||
if err != nil || !strings.Contains(out, "OK") {
|
||||
fmt.Printf(" [镜像] apt 切换国内源失败,将使用官方源: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" [镜像] apt 已切换至%s\n", mirrorLabel)
|
||||
}
|
||||
case "dnf", "yum":
|
||||
// 清华镜像站对 dnf/yum 提供 repo 文件;这里用变量临时指向(持久化需改 repo)
|
||||
cmd := `echo "腾讯/清华镜像需手动配置 repo;尝试临时使用 mirrors.tuna.tsinghua.edu.cn 变量"`
|
||||
runPrivileged(client, cmd, sudoPwd)
|
||||
// 通过 baseurl 变量临时覆盖(仅对部分仓库有效,作为加速兜底)
|
||||
fmt.Printf(" [镜像] %s 使用系统默认源(建议手动配置%s repo)\n", pm, mirrorLabel)
|
||||
case "apk":
|
||||
runPrivileged(client, "sed -i 's#https\\?://[^/]*alpinelinux.org#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' /etc/apk/repositories", sudoPwd)
|
||||
fmt.Printf(" [镜像] apk 已切换至%s\n", mirrorLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// 远程工具检测
|
||||
// ============================
|
||||
|
||||
// 压测核心依赖工具(缺则按需在线安装)。lm-sensors 依赖较多,仅单文件
|
||||
// 二进制 stress-ng 与 fio 为必需;sensors 缺失仅影响温度曲线,不阻断压测。
|
||||
var requiredTools = []string{"stress-ng", "fio"}
|
||||
|
||||
// ToolInfo 远程工具信息
|
||||
type ToolInfo struct {
|
||||
Name string
|
||||
@@ -47,31 +120,185 @@ func DetectTools(client *ssh.Client) ToolSet {
|
||||
return ts
|
||||
}
|
||||
|
||||
// detectOne 检测单个工具
|
||||
// EnsureTools 检测工具;缺失且 autoInstall=true 时按发行版自动安装。
|
||||
// sudoPwd 为非 root 用户执行安装时提供(为空则尝试免密 sudo)。
|
||||
// 返回安装后的工具集合,以及最终仍缺失的工具名列表。
|
||||
func EnsureTools(client *ssh.Client, names []string, autoInstall bool, sudoPwd string) (ToolSet, []string) {
|
||||
ts := DetectTools(client)
|
||||
var missing []string
|
||||
for _, n := range names {
|
||||
if !ts.Has(n) {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
var failed []string
|
||||
if autoInstall {
|
||||
pm := detectPkgManager(client)
|
||||
if pm == "" {
|
||||
return ts, missing // 无法识别包管理器,跳过安装
|
||||
}
|
||||
// 安装前先把源切到国内镜像(使用官方源在网络差的环境会超时)
|
||||
setupMirror(client, pm, sudoPwd)
|
||||
// 仅 apt 系需要先刷新元数据
|
||||
if pm == "apt-get" {
|
||||
if out, err := runPrivileged(client, "timeout 180 apt-get update 2>&1", sudoPwd); err != nil || strings.Contains(out, "Err") || strings.Contains(out, "Failed") {
|
||||
fmt.Printf(" [安装] apt-get update 异常(可能无可用源/无权限): %v\n%s\n", err, truncate(out, 400))
|
||||
}
|
||||
}
|
||||
for _, n := range missing {
|
||||
pkg := pkgName(pm, n)
|
||||
if pkg == "" {
|
||||
failed = append(failed, n)
|
||||
continue
|
||||
}
|
||||
installCmd := pkgInstallCmd(pm, pkg)
|
||||
// runPrivileged 统一处理 sudo 与引号,这里直接给裸安装命令(带 timeout 兜底)
|
||||
out, err := runPrivileged(client, fmt.Sprintf("timeout 300 %s 2>&1", installCmd), sudoPwd)
|
||||
if err != nil || !strings.Contains(out, "Setting up "+pkg) && !strings.Contains(out, "已安装") && !strings.Contains(out, "already newest") {
|
||||
fmt.Printf(" [安装] %s 安装失败: %v\n%s\n", pkg, err, truncate(out, 500))
|
||||
}
|
||||
// 安装后重新检测该工具是否到位
|
||||
if info, ok := detectOne(client, n); ok {
|
||||
ts.Tools[n] = info
|
||||
} else {
|
||||
failed = append(failed, n)
|
||||
}
|
||||
}
|
||||
if len(failed) == 0 {
|
||||
return ts, nil
|
||||
}
|
||||
return ts, failed
|
||||
}
|
||||
return ts, missing
|
||||
}
|
||||
|
||||
// runPrivileged 以特权执行命令:root 直接执行;非 root 用 sudo,
|
||||
// 若提供了 sudo 密码则通过 stdin 喂入(无需 NOPASSWD 配置)。
|
||||
func runPrivileged(client *ssh.Client, cmd, sudoPwd string) (string, error) {
|
||||
// 已是 root 则直接执行
|
||||
if out, err := sshclient.RunCommand(client, "id -u"); err == nil && strings.TrimSpace(out) == "0" {
|
||||
return sshclient.RunCommand(client, cmd)
|
||||
}
|
||||
// 非 root:尝试 sudo
|
||||
if sudoPwd != "" {
|
||||
// echo 密码 | sudo -S 执行;整条命令用双引号包裹,避免命令内部单引号嵌套冲突。
|
||||
// 仅对命令内的双引号转义(安装命令均不含双引号,安全)。
|
||||
quoted := strings.ReplaceAll(singleLine(cmd), `"`, `\"`)
|
||||
wrapped := fmt.Sprintf("echo %s | sudo -S -- sh -c \"%s\"", shellQuote(sudoPwd), quoted)
|
||||
return sshclient.RunCommand(client, wrapped)
|
||||
}
|
||||
// 无密码:先尝试免密 sudo (-n),失败则回退普通 sudo(会提示需要密码)
|
||||
quoted := strings.ReplaceAll(singleLine(cmd), `"`, `\"`)
|
||||
if out, err := sshclient.RunCommand(client, "sudo -n -- sh -c \""+quoted+"\""); err == nil {
|
||||
return out, nil
|
||||
}
|
||||
return sshclient.RunCommand(client, "sudo -- sh -c \""+quoted+"\"")
|
||||
}
|
||||
|
||||
// shellQuote 转义单引号,避免密码破坏命令结构
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// singleLine 去掉换行,保证能塞进 sh -c '...'
|
||||
func singleLine(s string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", "")
|
||||
}
|
||||
|
||||
// truncate 截断过长的输出,便于日志展示
|
||||
func truncate(s string, n int) string {
|
||||
// 去掉 ANSI/多余空行,只保留前 n 个字符
|
||||
s = strings.TrimSpace(s)
|
||||
if len([]rune(s)) > n {
|
||||
return string([]rune(s)[:n]) + "...(truncated)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// detectPkgManager 识别远程发行版的包管理器
|
||||
func detectPkgManager(client *ssh.Client) string {
|
||||
for _, pm := range []string{"apt-get", "dnf", "yum", "apk", "zypper"} {
|
||||
if _, err := sshclient.RunCommand(client, fmt.Sprintf("command -v %s", pm)); err == nil {
|
||||
return pm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pkgName 工具名 → 对应发行版的包名
|
||||
func pkgName(pm, tool string) string {
|
||||
m := map[string]string{
|
||||
"stress-ng": "stress-ng",
|
||||
"fio": "fio",
|
||||
"lm-sensors": "lm-sensors",
|
||||
"stressapptest": "stressapptest",
|
||||
"iperf3": "iperf3",
|
||||
}
|
||||
if pm == "dnf" || pm == "yum" {
|
||||
// RHEL 系 lm-sensors 包名为 lm_sensors
|
||||
if tool == "lm-sensors" {
|
||||
return "lm_sensors"
|
||||
}
|
||||
}
|
||||
if pm == "zypper" {
|
||||
if tool == "lm-sensors" {
|
||||
return "sensors"
|
||||
}
|
||||
}
|
||||
if v, ok := m[tool]; ok {
|
||||
return v
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
// pkgInstallCmd 生成安装命令
|
||||
func pkgInstallCmd(pm, pkg string) string {
|
||||
switch pm {
|
||||
case "apt-get":
|
||||
return fmt.Sprintf("apt-get install -y %s", pkg)
|
||||
case "dnf":
|
||||
return fmt.Sprintf("dnf install -y %s", pkg)
|
||||
case "yum":
|
||||
return fmt.Sprintf("yum install -y %s", pkg)
|
||||
case "apk":
|
||||
return fmt.Sprintf("apk add %s", pkg)
|
||||
case "zypper":
|
||||
return fmt.Sprintf("zypper install -y %s", pkg)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectOne 检测单个工具(查系统 PATH;缺失由 EnsureTools 在线安装补全)
|
||||
func detectOne(client *ssh.Client, name string) (ToolInfo, bool) {
|
||||
out, err := sshclient.RunCommand(client, "which "+name+" 2>/dev/null")
|
||||
path := strings.TrimSpace(out)
|
||||
if err != nil || path == "" {
|
||||
path := ""
|
||||
if out, err := sshclient.RunCommand(client, "which "+name+" 2>/dev/null"); err == nil {
|
||||
path = strings.TrimSpace(out)
|
||||
}
|
||||
if path == "" {
|
||||
return ToolInfo{}, false
|
||||
}
|
||||
|
||||
var version string
|
||||
switch name {
|
||||
case "stress-ng":
|
||||
out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||
version = strings.TrimSpace(out)
|
||||
v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||
version = strings.TrimSpace(v)
|
||||
case "stressapptest":
|
||||
out, _ = sshclient.RunCommand(client, path+" --help 2>&1 | head -1")
|
||||
version = strings.TrimSpace(out)
|
||||
v, _ := sshclient.RunCommand(client, path+" --help 2>&1 | head -1")
|
||||
version = strings.TrimSpace(v)
|
||||
case "iperf3":
|
||||
out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||
version = strings.TrimSpace(out)
|
||||
v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||
version = strings.TrimSpace(v)
|
||||
case "fio":
|
||||
out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||
version = strings.TrimSpace(out)
|
||||
v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||
version = strings.TrimSpace(v)
|
||||
case "lm-sensors":
|
||||
out, _ = sshclient.RunCommand(client, path+" -v 2>&1 | head -1")
|
||||
version = strings.TrimSpace(out)
|
||||
v, _ := sshclient.RunCommand(client, path+" -v 2>&1 | head -1")
|
||||
version = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
return ToolInfo{Name: name, Path: path, Version: version}, true
|
||||
|
||||
@@ -35,14 +35,128 @@ type Result struct {
|
||||
Output string
|
||||
Error string
|
||||
Duration time.Duration
|
||||
Samples []Sample // 时序采样数据(cpu 等测试采集)
|
||||
Samples []Sample // 时序采样数据(用于绘制曲线)
|
||||
Score float64 // 性能跑分(越大越好,单位见 MetricUnit)
|
||||
Metrics map[string]float64 // 关键指标(最高温度、IOPS、吞吐等明细)
|
||||
}
|
||||
|
||||
// Sample 单次采样点
|
||||
// Sample 单次采样点(通用多指标:时间 + 各指标值)
|
||||
// 指标名由脚本 SAMPLES 块首行表头决定,例如 cpu/temp/mem_used_pct/iops_total/bw_total
|
||||
type Sample struct {
|
||||
Time string // HH:MM:SS
|
||||
CPU float64 // CPU 使用率 %
|
||||
Temp float64 // 温度 °C
|
||||
Time string // 时间(HH:MM:SS 或相对秒)
|
||||
Values map[string]float64 // 指标名 → 值
|
||||
}
|
||||
|
||||
// Get 读取某个指标在该采样点的值(不存在返回 0)
|
||||
func (s Sample) Get(name string) float64 {
|
||||
return s.Values[name]
|
||||
}
|
||||
|
||||
// HasMetric 采样点是否包含某指标
|
||||
func (s Sample) HasMetric(name string) bool {
|
||||
_, ok := s.Values[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ScoreUnit 返回某类测试跑分的单位
|
||||
func ScoreUnit(t TestType) string {
|
||||
switch t {
|
||||
case TestDiskIO:
|
||||
return "MB/s"
|
||||
default:
|
||||
return "ops/s"
|
||||
}
|
||||
}
|
||||
|
||||
// metricLabels 关键指标中文名映射
|
||||
var metricLabels = map[string]string{
|
||||
"max_temp": "最高温度",
|
||||
"avg_temp": "平均温度",
|
||||
"avg_cpu": "平均CPU利用率",
|
||||
"mem_used_pct": "峰值内存使用率",
|
||||
"avg_mem": "平均内存使用率",
|
||||
"read_iops": "读IOPS",
|
||||
"write_iops": "写IOPS",
|
||||
"iops_total": "总IOPS",
|
||||
"read_bw": "读吞吐",
|
||||
"write_bw": "写吞吐",
|
||||
"bw_total": "总吞吐",
|
||||
}
|
||||
|
||||
// MetricLabel 返回指标显示名,未知指标原样返回
|
||||
func MetricLabel(name string) string {
|
||||
if l, ok := metricLabels[name]; ok {
|
||||
return l
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// MetricUnit 返回指标单位,未知指标返回空
|
||||
func MetricUnit(name string) string {
|
||||
switch name {
|
||||
case "max_temp", "avg_temp":
|
||||
return "°C"
|
||||
case "read_bw", "write_bw", "bw_total":
|
||||
return "MB/s"
|
||||
case "avg_cpu", "mem_used_pct", "avg_mem":
|
||||
return "%"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// sortedMetricNames 按固定顺序返回指标名(保证报告输出稳定)
|
||||
func sortedMetricNames(m map[string]float64) []string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
order := []string{"max_temp", "avg_temp", "avg_cpu", "mem_used_pct", "avg_mem",
|
||||
"read_iops", "write_iops", "iops_total", "read_bw", "write_bw", "bw_total"}
|
||||
names := make([]string, 0, len(m))
|
||||
for _, k := range order {
|
||||
if _, ok := m[k]; ok {
|
||||
names = append(names, k)
|
||||
}
|
||||
}
|
||||
// 兜底:未在 order 里的键追加
|
||||
for k := range m {
|
||||
found := false
|
||||
for _, n := range names {
|
||||
if n == k {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
names = append(names, k)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// MetricItem 一条指标明细(已格式化,供报告/打印使用)
|
||||
type MetricItem struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Label string `json:"label" yaml:"label"`
|
||||
Value float64 `json:"value" yaml:"value"`
|
||||
Unit string `json:"unit,omitempty" yaml:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// OrderedMetrics 返回按固定顺序排列的指标明细列表
|
||||
func (r Result) OrderedMetrics() []MetricItem {
|
||||
if len(r.Metrics) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]MetricItem, 0, len(r.Metrics))
|
||||
for _, name := range sortedMetricNames(r.Metrics) {
|
||||
items = append(items, MetricItem{
|
||||
Name: name,
|
||||
Label: MetricLabel(name),
|
||||
Value: r.Metrics[name],
|
||||
Unit: MetricUnit(name),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// Report 完整压测报告
|
||||
@@ -52,6 +166,7 @@ type Report struct {
|
||||
EndTime time.Time
|
||||
Duration time.Duration
|
||||
Results []Result
|
||||
SysInfo map[string]string // 系统信息(hostname/cpu/cores/memory/kernel/disk)
|
||||
Passed int
|
||||
Failed int
|
||||
Skipped int
|
||||
@@ -96,6 +211,13 @@ func (r *Report) ToText() string {
|
||||
icon = "⚠"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s %-12s %s\n", icon, res.Type, res.Status))
|
||||
if res.Score > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 跑分: %.1f %s\n", res.Score, ScoreUnit(res.Type)))
|
||||
}
|
||||
for _, name := range sortedMetricNames(res.Metrics) {
|
||||
unit := MetricUnit(name)
|
||||
sb.WriteString(fmt.Sprintf(" %s: %.1f%s\n", MetricLabel(name), res.Metrics[name], unit))
|
||||
}
|
||||
for _, line := range strings.Split(res.Output, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", line))
|
||||
|
||||
Reference in New Issue
Block a user