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

@@ -16,13 +16,16 @@ ssh:
# --- 压力测试 ---
stress:
types: "cpu,memory"
types: "cpu,memory,disk,full" # 单项在前、综合(full)最后
duration: 30s
threads: 4
temp_limit: 90 # 温度上限(°C)超过判失败0=不判定
auto_install: true # 缺工具时在线安装apt/dnf/yum 等),需要目标机可联网
use_mirror: false # 在线安装是否切换国内镜像源(清华TUNA)。国内网络卡顿/超时再改 true
# --- 工作流 ---
workflow:
interval: 60s
interval: 180s
# --- 报告与打印 ---
report:

View File

@@ -5,12 +5,12 @@ go 1.25.0
require (
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.54.0
golang.org/x/text v0.41.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -16,6 +16,7 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -38,13 +38,17 @@ type SSHConfig struct {
Password string `yaml:"password"`
KeyFile string `yaml:"key"`
Port int `yaml:"port"`
SudoPwd string `yaml:"sudo_password"` // 非 root 用户安装工具时使用的 sudo 密码
}
// StressConfig 压力测试
type StressConfig struct {
Types string `yaml:"types"`
Duration time.Duration `yaml:"duration"`
Threads int `yaml:"threads"`
Types string `yaml:"types"`
Duration time.Duration `yaml:"duration"`
Threads int `yaml:"threads"`
TempLimit float64 `yaml:"temp_limit"` // 温度上限(°C)0=不判定
AutoInstall bool `yaml:"auto_install"` // 缺工具时尝试在线安装apt/dnf/yum 等)
UseMirror bool `yaml:"use_mirror"` // 在线安装时切换国内镜像源(清华 TUNA国内网络卡顿/超时再开启
}
// ReportConfig 报告
@@ -83,9 +87,12 @@ func Default() *Config {
Port: 22,
},
Stress: StressConfig{
Types: "cpu,memory",
Duration: 30 * time.Second,
Threads: 4,
Types: "cpu,memory",
Duration: 30 * time.Second,
Threads: 4,
TempLimit: 90,
AutoInstall: true,
UseMirror: false,
},
Workflow: WorkflowConfig{
Interval: 10 * time.Second,

View File

@@ -18,11 +18,14 @@ func BuildDeviceReport(dev *model.Device, rpt *stress.Report) *Report {
b.AddHost(HostResult{MAC: dev.MAC, IP: dev.IP})
for _, res := range rpt.Results {
b.AddStressResult(dev.MAC, StressResult{
Type: string(res.Type),
Status: res.Status,
Duration: res.Duration.String(),
Error: res.Error,
Output: res.Output,
Type: string(res.Type),
Status: res.Status,
Duration: res.Duration.String(),
Score: res.Score,
ScoreUnit: stress.ScoreUnit(res.Type),
Metrics: res.OrderedMetrics(),
Error: res.Error,
Output: res.Output,
})
}
b.EndPhase(rpt.Summary())

View File

@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"time"
"auto-check/pkg/stress"
)
// PhaseResult 单阶段结果
@@ -19,23 +21,25 @@ type PhaseResult struct {
// HostResult 单台设备结果MAC 为唯一标识)
type HostResult struct {
MAC string `json:"mac" yaml:"mac"`
IP string `json:"ip" yaml:"ip"`
Alive bool `json:"alive" yaml:"alive"`
OpenPort int `json:"open_port,omitempty" yaml:"open_port,omitempty"`
SSHLogin bool `json:"ssh_login" yaml:"ssh_login"`
SSHErr string `json:"ssh_error,omitempty" yaml:"ssh_error,omitempty"`
Stress []StressResult `json:"stress,omitempty" yaml:"stress,omitempty"`
Curves map[string][]float64 `json:"curves,omitempty" yaml:"curves,omitempty"` // 指标名 → 时间序列(渲染为 sparkline
MAC string `json:"mac" yaml:"mac"`
IP string `json:"ip" yaml:"ip"`
Alive bool `json:"alive" yaml:"alive"`
OpenPort int `json:"open_port,omitempty" yaml:"open_port,omitempty"`
SSHLogin bool `json:"ssh_login" yaml:"ssh_login"`
SSHErr string `json:"ssh_error,omitempty" yaml:"ssh_error,omitempty"`
Stress []StressResult `json:"stress,omitempty" yaml:"stress,omitempty"`
}
// StressResult 单项压力测试结果
type StressResult struct {
Type string `json:"type" yaml:"type"`
Status string `json:"status" yaml:"status"` // pass / fail / skip
Duration string `json:"duration" yaml:"duration"`
Output string `json:"output,omitempty" yaml:"output,omitempty"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
Type string `json:"type" yaml:"type"`
Status string `json:"status" yaml:"status"` // pass / fail / skip
Duration string `json:"duration" yaml:"duration"`
Score float64 `json:"score,omitempty" yaml:"score,omitempty"`
ScoreUnit string `json:"score_unit,omitempty" yaml:"score_unit,omitempty"`
Metrics []stress.MetricItem `json:"metrics,omitempty" yaml:"metrics,omitempty"`
Output string `json:"output,omitempty" yaml:"output,omitempty"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
// Report 检测报告
@@ -209,6 +213,12 @@ func (r *Report) ToText() string {
icon = "✗"
}
sb.WriteString(fmt.Sprintf(" │ %s %-10s 耗时: %s\n", icon, s.Type, s.Duration))
if s.Score > 0 {
sb.WriteString(fmt.Sprintf(" │ 跑分: %.1f %s\n", s.Score, s.ScoreUnit))
}
for _, m := range s.Metrics {
sb.WriteString(fmt.Sprintf(" │ %s: %.1f%s\n", m.Label, m.Value, m.Unit))
}
if s.Error != "" {
sb.WriteString(fmt.Sprintf(" │ 错误: %s\n", s.Error))
}

View File

@@ -2,7 +2,6 @@ package report
import (
"fmt"
"math"
"strings"
"text/template"
"time"
@@ -50,29 +49,6 @@ func RenderTemplate(tpl string, data interface{}, cfg TemplateConfig) (string, e
},
// 分隔线
"hr": func(sharp rune) string { return strings.Repeat(string(sharp), cfg.Width) },
// 曲线图(时间序列 → sparkline
"sparkline": func(data []float64, width int) string {
return Sparkline(data, width)
},
// 曲线摘要: 最高/最低/平均
"curveSummary": func(data []float64) string {
if len(data) == 0 {
return "无数据"
}
min, max := math.MaxFloat64, -math.MaxFloat64
var sum float64
for _, v := range data {
if v < min {
min = v
}
if v > max {
max = v
}
sum += v
}
avg := sum / float64(len(data))
return fmt.Sprintf("max=%.1f min=%.1f avg=%.1f", max, min, avg)
},
}
t, err := template.New("report").Funcs(funcMap).Parse(tpl)
@@ -86,76 +62,3 @@ func RenderTemplate(tpl string, data interface{}, cfg TemplateConfig) (string, e
}
return sb.String(), nil
}
// ============================
// Sparkline — 80mm 热敏纸曲线图
// ============================
// sparkChars 8 级块状字符(低→高)
var sparkChars = []rune("▁▂▃▄▅▆▇█")
// Sparkline 将时间序列渲染为一行块状曲线
// width 为字符宽度80mm 纸 ≤42
func Sparkline(data []float64, width int) string {
if len(data) == 0 {
return "(无数据)"
}
if width <= 0 {
width = 42
}
if width > 42 {
width = 42
}
// 单点
if len(data) == 1 {
return fmt.Sprintf("▊ %.1f", data[0])
}
// 按宽度采样(超出宽度时等距抽取)
step := float64(len(data)) / float64(width)
sampled := make([]float64, 0, width)
for i := 0; i < width && int(float64(i)*step) < len(data); i++ {
idx := int(float64(i) * step)
if idx >= len(data) {
break
}
sampled = append(sampled, data[idx])
}
// 归一化到 0-7
min, max := math.MaxFloat64, -math.MaxFloat64
for _, v := range sampled {
if v < min {
min = v
}
if v > max {
max = v
}
}
var sb strings.Builder
if max-min < 1e-9 {
// 全相同值
idx := 0
if max > 0 {
idx = 3
}
for range sampled {
sb.WriteRune(sparkChars[idx])
}
return sb.String()
}
for _, v := range sampled {
idx := int((v - min) / (max - min) * 7)
if idx < 0 {
idx = 0
}
if idx > 7 {
idx = 7
}
sb.WriteRune(sparkChars[idx])
}
return sb.String()
}

View File

@@ -18,9 +18,8 @@
{{ end }}{{ end }}{{ if eq .Phase "SSH 登录" }}{{ range .Hosts }}{{ if .SSHLogin }}✓{{ else }}✗{{ end }} {{ padRight .MAC 19 }} {{ padRight .IP 15 }} {{ .SSHErr }}
{{ end }}{{ end }}{{ if eq .Phase "压力测试" }}{{ range .Hosts }}{{ padRight .MAC 19 }} {{ padRight .IP 15 }}
{{ range .Stress }}{{ if eq .Status "pass" }}✓{{ else }}✗{{ end }} {{ padRight .Type 12 }} {{ .Status }} {{ .Duration }}
{{ if .Error }} ERR: {{ trunc .Error 34 }}{{ end }}{{ end }}{{ range $name, $data := .Curves }} [{{ $name }}] {{ curveSummary $data }}
{{ sparkline $data 38 }}
{{ end }}{{ end }}{{ end }}{{ end }}
{{ if .Score }} 跑分: {{ printf "%.1f" .Score }} {{ .ScoreUnit }}{{ end }}{{ range .Metrics }} {{ .Label }}: {{ printf "%.1f" .Value }}{{ .Unit }}
{{ end }}{{ if .Error }} ERR: {{ trunc .Error 34 }}{{ end }}{{ end }}{{ end }}{{ end }}{{ end }}
{{ "" }}
{{ hr '═' }}
报告结束

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net"
"os"
"strings"
"time"
"golang.org/x/crypto/ssh"
@@ -16,6 +17,7 @@ type Config struct {
KeyFile string
Port int
Timeout time.Duration
SudoPwd string // 非 root 用户执行特权命令(sudo)时的密码
}
// NewConfig 创建配置
@@ -54,9 +56,61 @@ func RunCommand(client *ssh.Client, command string) (string, error) {
out, err := session.CombinedOutput(command)
if err != nil {
return string(out), fmt.Errorf("命令执行失败: %w", err)
return cleanOutput(out), fmt.Errorf("命令执行失败: %w", err)
}
return string(out), nil
return cleanOutput(out), nil
}
// isNoiseLine 判断某行是否为登录横幅/欢迎语等噪声fnOS 的 admin 用户会把
// "Could not chdir to home directory" 之类写入 stdout污染命令解析
func isNoiseLine(line string) bool {
s := strings.TrimSpace(line)
if s == "" {
return false
}
prefixes := []string{
"Could not chdir to",
"Last login:",
"Welcome to",
"/etc/motd",
"Permission denied",
"bash: line 1:",
}
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
// 某些横幅是 "Could: command not found" 这类由前面噪声行残生,
// 形如 "xxx: command not found" 且前半段是噪声关键词的也过滤
if strings.Contains(s, ": command not found") {
return true
}
return false
}
// cleanOutput 过滤掉登录横幅等噪声行,仅保留真实命令输出
func cleanOutput(raw []byte) string {
lines := strings.Split(string(raw), "\n")
var kept []string
for _, ln := range lines {
if isNoiseLine(ln) {
continue
}
kept = append(kept, ln)
}
return strings.Join(kept, "\n")
}
// EnsureHome 确保远端登录用户的 HOME 目录存在,消除 fnOS "Could not chdir to
// home directory" 登录横幅(该横幅会写入 stdout 污染命令输出)。
func EnsureHome(client *ssh.Client) {
out, err := RunCommand(client, "echo $HOME")
home := strings.TrimSpace(out)
if err != nil || home == "" || home == "/" {
return
}
RunCommand(client, fmt.Sprintf("mkdir -p %s", home))
}
// buildSSHConfig 构建认证配置

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>`

View File

@@ -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,
}
}

View 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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>`

View File

@@ -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 {

View File

@@ -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

View File

@@ -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))

View File

@@ -71,6 +71,10 @@ func (w *Workflow) round() {
fmt.Printf(" [测试] %s (%s) 开始压测...\n", ip, dev.MAC)
rpt := stress.TestDevice(ip, w.cfg)
stress.Results[ip] = rpt
if stress.IsSSHFail(rpt) {
fmt.Printf(" [测试] %s SSH 连接失败,跳过报告输出: %s\n", ip, rpt.Results[0].Error)
continue
}
report.SaveAndPrintDeviceReport(&dev, rpt, cfg.Report.Path, cfg.Report.Print)
}

0
auto-check/run.err Normal file
View File

142
auto-check/run.log Normal file
View File

@@ -0,0 +1,142 @@
[配置] 加载完成: 网段=10.0.3.0/24 间隔=3m0s 压测=cpu,memory,disk,full
══ 工作流启动(间隔 3m0s设备以 IP 为 key══
════ 轮次开始 [13:42:10] ════
[扫描] 网段 10.0.3.0/24共 254 个IP10 并发 ping 探测...
[扫描] 完成,发现 11 台存活设备
[扫描] 设备 11 台
[测试] 10.0.3.1 () 开始压测...
[测试] 10.0.3.1 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.1:22 失败: dial tcp 10.0.3.1:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.104 () 开始压测...
[测试] 10.0.3.104 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.104:22 失败: dial tcp 10.0.3.104:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.123 () 开始压测...
[测试] 10.0.3.123 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.123:22 失败: dial tcp 10.0.3.123:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.152 () 开始压测...
[测试] 10.0.3.152 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.152:22 失败: dial tcp 10.0.3.152:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.147 () 开始压测...
[测试] 10.0.3.147 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.147:22 失败: dial tcp 10.0.3.147:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.148 () 开始压测...
[MAC] 10.0.3.148 -> fe:49:6a:ab:e1:79
════════ [10.0.3.148] 压力测试开始 ════════
工具: stress-ng(stress-ng, version 0.15.06 (gcc 12.2.0, x86_64 Linux 6.18.18.c877-trim)) iperf3(iperf 3.12 (cJSON 1.7.15)) fio(fio-3.33)
指标: cpu memory disk full dmesg
[脚本] 拼装完成10334 字节
[报告] reports/report-10.0.3.148.html
════════ [10.0.3.148] 压力测试完成 ════════
══ [10.0.3.148] 压力测试报告 ══
耗时: 2m18.501s
✓ cpu pass
跑分: 1285.6 ops/s
最高温度: 57.0°C
平均CPU利用率: 99.1%
stress-ng: info: [3652909] Working directory / is not read/writeable, some I/O tests may fail
stress-ng: info: [3652909] setting to a 30 second run per stressor
stress-ng: info: [3652909] dispatching hogs: 4 cpu
stress-ng: metrc: [3652909] stressor bogo ops real time usr time sys time bogo ops/s bogo ops/s
stress-ng: metrc: [3652909] (secs) (secs) (secs) (real time) (usr+sys time)
stress-ng: metrc: [3652909] cpu 38577 30.01 90.66 0.43 1285.60 423.51
stress-ng: info: [3652909] successful run completed in 30.02s
✓ memory pass
跑分: 33472.4 ops/s
最高温度: 58.0°C
峰值内存使用率: 57.1%
stress-ng: info: [3653312] Working directory / is not read/writeable, some I/O tests may fail
stress-ng: info: [3653312] setting to a 30 second run per stressor
stress-ng: info: [3653312] dispatching hogs: 4 vm
stress-ng: metrc: [3653312] stressor bogo ops real time usr time sys time bogo ops/s bogo ops/s
stress-ng: metrc: [3653312] (secs) (secs) (secs) (real time) (usr+sys time)
stress-ng: metrc: [3653312] vm 1006646 30.07 47.26 5.75 33472.37 18990.03
stress-ng: info: [3653312] successful run completed in 30.28s
✓ disk pass
跑分: 445.0 MB/s
读IOPS: 77.0
写IOPS: 34.0
总IOPS: 111.0
读吞吐: 309.0MB/s
写吞吐: 136.0MB/s
总吞吐: 445.0MB/s
autocheck: (g=0): rw=randrw, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=psync, iodepth=32
fio-3.33
Starting 1 process
autocheck: Laying out IO file (1 file / 1024MiB)
note: both iodepth >= 1 and synchronous I/O engine are selected, queue depth will be capped at 1
autocheck: (groupid=0, jobs=1): err= 0: pid=3653771: Thu Aug 13 13:44:37 2026
read: IOPS=77, BW=309KiB/s (317kB/s)(9276KiB/30005msec)
clat (usec): min=253, max=830522, avg=11740.11, stdev=28335.80
lat (usec): min=255, max=830525, avg=11742.04, stdev=28335.81
clat percentiles (msec):
| 1.00th=[ 4], 5.00th=[ 5], 10.00th=[ 6], 20.00th=[ 7],
| 30.00th=[ 8], 40.00th=[ 9], 50.00th=[ 11], 60.00th=[ 12],
| 70.00th=[ 13], 80.00th=[ 14], 90.00th=[ 15], 95.00th=[ 16],
| 99.00th=[ 59], 99.50th=[ 65], 99.90th=[ 676], 99.95th=[ 802],
| 99.99th=[ 835]
bw ( KiB/s): min= 28, max= 420, per=98.98%, avg=306.62, stdev=113.30, samples=29
iops : min= 7, max= 105, avg=76.55, stdev=28.40, samples=29
write: IOPS=34, BW=136KiB/s (140kB/s)(4088KiB/30005msec); 0 zone resets
clat (usec): min=131, max=841470, avg=2638.71, stdev=36403.27
lat (usec): min=131, max=841473, avg=2641.68, stdev=36403.26
clat percentiles (usec):
| 1.00th=[ 188], 5.00th=[ 269], 10.00th=[ 318], 20.00th=[ 383],
| 30.00th=[ 449], 40.00th=[ 506], 50.00th=[ 586], 60.00th=[ 685],
| 70.00th=[ 824], 80.00th=[ 979], 90.00th=[ 1254], 95.00th=[ 1483],
| 99.00th=[ 9372], 99.50th=[ 15270], 99.90th=[767558], 99.95th=[843056],
| 99.99th=[843056]
bw ( KiB/s): min= 8, max= 211, per=99.09%, avg=135.72, stdev=55.82, samples=29
iops : min= 2, max= 52, avg=33.83, stdev=14.03, samples=29
lat (usec) : 250=1.11%, 500=10.72%, 750=8.20%, 1000=5.00%
lat (msec) : 2=4.88%, 4=1.65%, 10=33.49%, 20=33.10%, 50=0.57%
lat (msec) : 100=1.11%, 250=0.03%, 750=0.03%, 1000=0.12%
cpu : usr=0.46%, sys=2.25%, ctx=3357, majf=0, minf=13
IO depths : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
issued rwts: total=2319,1022,0,0 short=0,0,0,0 dropped=0,0,0,0
latency : target=0, window=0, percentile=100.00%, depth=32
Run status group 0 (all jobs):
READ: bw=309KiB/s (317kB/s), 309KiB/s-309KiB/s (317kB/s-317kB/s), io=9276KiB (9499kB), run=30005-30005msec
WRITE: bw=136KiB/s (140kB/s), 136KiB/s-136KiB/s (140kB/s-140kB/s), io=4088KiB (4186kB), run=30005-30005msec
Disk stats (read/write):
sda: ios=2319/1057, merge=0/81, ticks=26764/7589, in_queue=38611, util=97.50%
✓ full pass
跑分: 40708.6 ops/s
最高温度: 58.0°C
平均CPU利用率: 95.8%
峰值内存使用率: 51.2%
stress-ng: info: [3653901] Working directory / is not read/writeable, some I/O tests may fail
stress-ng: info: [3653901] setting to a 30 second run per stressor
stress-ng: info: [3653901] dispatching hogs: 4 cpu, 4 vm, 2 iomix
stress-ng: metrc: [3653901] stressor bogo ops real time usr time sys time bogo ops/s bogo ops/s
stress-ng: metrc: [3653901] (secs) (secs) (secs) (real time) (usr+sys time)
stress-ng: metrc: [3653901] cpu 22552 30.02 54.07 0.35 751.21 414.42
stress-ng: metrc: [3653901] vm 1193352 30.01 45.94 3.66 39767.94 24057.59
stress-ng: metrc: [3653901] iomix 5692 30.04 2.13 4.02 189.48 924.72
stress-ng: info: [3653901] successful run completed in 30.08s
✓ dmesg pass
无硬件相关内核报错
共 5 项: 5 通过, 0 失败, 0 跳过, 0 错误
[测试] 10.0.3.162 () 开始压测...
[测试] 10.0.3.162 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.162:22 失败: dial tcp 10.0.3.162:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.119 () 开始压测...
[测试] 10.0.3.119 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.119:22 失败: dial tcp 10.0.3.119:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.160 () 开始压测...
[测试] 10.0.3.160 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.160:22 失败: dial tcp 10.0.3.160:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.171 () 开始压测...
[测试] 10.0.3.171 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.171:22 失败: dial tcp 10.0.3.171:22: connectex: No connection could be made because the target machine actively refused it.
[测试] 10.0.3.236 () 开始压测...
[测试] 10.0.3.236 SSH 连接失败,跳过报告输出: SSH 连接 10.0.3.236:22 失败: dial tcp 10.0.3.236:22: connectex: No connection could be made because the target machine actively refused it.
[状态] 10.0.3.160 ✗ → fail
[状态] 10.0.3.236 ✗ → fail
[状态] 10.0.3.1 ✗ → fail
[状态] 10.0.3.104 ✗ → fail
[状态] 10.0.3.147 ✗ → fail
[状态] 10.0.3.119 ✗ → fail
[状态] 10.0.3.171 ✗ → fail
[状态] 10.0.3.123 ✗ → fail
[状态] 10.0.3.152 ✗ → fail
[状态] 10.0.3.148 (fe:49:6a:ab:e1:79) ✓ → pass
[状态] 10.0.3.162 ✗ → fail

View File

@@ -1,13 +1,18 @@
echo "===TEST:cpu==="
# 读取温度sensors 不可用时输出空)
read_temp() {
sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1
}
# 后台采样 CPU 使用率 + 温度
SAMPLE_LOG=/tmp/auto-check-cpu-sample.log
> "$SAMPLE_LOG"
: > "$SAMPLE_LOG"
(
while true; do
TS=$(date +%H:%M:%S)
CPU=$(top -bn1 2>/dev/null | grep "Cpu(s)" | sed 's/.*,\s*\([0-9.]*\)%*\s*id.*/\1/' | awk '{printf "%.1f", 100-$1}' || echo 0)
TEMP=$(sensors 2>/dev/null | grep -iE 'Package|Core\s*0|temp1|CPU' | head -1 | grep -oP '\d+\.?\d*' | head -1 || echo 0)
CPU=$(top -bn1 2>/dev/null | grep "Cpu(s)" | sed -n 's/.*,\s*\([0-9.]*\)%*\s*id.*/\1/p' | awk '{printf "%.1f", 100-$1}' 2>/dev/null)
TEMP=$(read_temp)
echo "${TS},${CPU},${TEMP}" >> "$SAMPLE_LOG"
sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}"
done
@@ -16,7 +21,7 @@ SAMPLE_PID=$!
START_TIME=$(date +%s%N)
set +e
OUTPUT=$(stress-ng --cpu "${AUTOCHECK_THREADS:-4}" --cpu-method all --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief --temp-path /tmp 2>&1)
OUTPUT=$(stress-ng --cpu "${AUTOCHECK_THREADS:-4}" --cpu-method all --timeout "${AUTOCHECK_DURATION:-30}s" --temp-path /tmp --metrics-brief 2>&1)
EXIT_CODE=$?
set -e
END_TIME=$(date +%s%N)
@@ -27,10 +32,27 @@ wait $SAMPLE_PID 2>/dev/null || true
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
echo "duration:${DURATION_MS}ms"
# 跑分:各 stressor 的 bogo ops/s 之和
SCORE=$(echo "$OUTPUT" | awk '
/^stress-ng:/ && ($4=="cpu" || $4=="vm" || $4=="iomix" || $4=="memrate") {
v=$(NF-1)+0; if (v>0) s+=v
}
END { printf "%.1f", s+0 }')
echo "score:${SCORE}"
# 关键指标
AVG_CPU=$(awk -F',' '$2!="" {s+=$2; n++} END{printf "%.1f", (n?s/n:0)}' "$SAMPLE_LOG")
echo "metric:avg_cpu=${AVG_CPU}"
MAX_TEMP=$(awk -F',' '$3!="" && $3+0>m {m=$3} END{printf "%.1f", m+0}' "$SAMPLE_LOG")
HAS_TEMP=$(awk -F',' '$3!="" && $3+0>0{f=1} END{print f+0}' "$SAMPLE_LOG")
if [ "$HAS_TEMP" = "1" ]; then echo "metric:max_temp=${MAX_TEMP}"; fi
echo "output:${OUTPUT}"
# 输出采样数据
# 采样数据(首行表头)
echo "===SAMPLES==="
echo "time,cpu,temp"
cat "$SAMPLE_LOG"
echo "===END_SAMPLES==="

View File

@@ -1,14 +1,77 @@
echo "===TEST:disk==="
TEST_DIR=${AUTOCHECK_DISK_DIR:-/tmp/stress-disk-test}
DISK_SIZE_MB=${AUTOCHECK_DISK_SIZE_MB:-1024}
TEST_DIR=${AUTOCHECK_DISK_DIR:-/tmp/auto-check-disk}
mkdir -p "$TEST_DIR"
START_TIME=$(date +%s%N)
set +e
OUTPUT=$(mkdir -p "${TEST_DIR}" && stress-ng --iomix 2 --iomix-bytes "${DISK_SIZE_MB}M" --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1; rm -rf "${TEST_DIR}")
EXIT_CODE=$?
set -e
END_TIME=$(date +%s%N)
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
echo "duration:${DURATION_MS}ms"
echo "output:${OUTPUT}"
if command -v fio >/dev/null 2>&1; then
FIO_IMG="$TEST_DIR/fio.img"
BW_LOG="$TEST_DIR/ac-bw"
IOPS_LOG="$TEST_DIR/ac-iops"
rm -f "$BW_LOG"*.log "$IOPS_LOG"*.log "$FIO_IMG"
set +e
OUTPUT=$(fio --name=autocheck --rw=randrw --rwmixread=70 --bs=4k \
--size="${AUTOCHECK_DISK_SIZE_MB:-512}M" --iodepth=32 --numjobs=1 \
--runtime="${AUTOCHECK_DURATION:-30}" --time_based --direct=1 \
--group_reporting --output-format=normal \
--write_bw_log="$BW_LOG" --write_iops_log="$IOPS_LOG" \
--log_avg_msec=1000 --filename="$FIO_IMG" 2>&1)
EXIT_CODE=$?
set -e
READ_LINE=$(echo "$OUTPUT" | grep -E '^[[:space:]]*read:' | head -1)
WRITE_LINE=$(echo "$OUTPUT" | grep -E '^[[:space:]]*write:' | head -1)
READ_IOPS=$(echo "$READ_LINE" | grep -oP 'IOPS=\K[0-9.]+' | head -1)
READ_BW=$(echo "$READ_LINE" | grep -oP 'BW=\K[0-9.]+' | head -1)
WRITE_IOPS=$(echo "$WRITE_LINE" | grep -oP 'IOPS=\K[0-9.]+' | head -1)
WRITE_BW=$(echo "$WRITE_LINE" | grep -oP 'BW=\K[0-9.]+' | head -1)
SCORE=$(awk -v r="${READ_BW:-0}" -v w="${WRITE_BW:-0}" 'BEGIN{printf "%.1f", r+w}')
IOPS_TOTAL=$(awk -v r="${READ_IOPS:-0}" -v w="${WRITE_IOPS:-0}" 'BEGIN{printf "%.1f", r+w}')
END_TIME=$(date +%s%N)
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
echo "duration:${DURATION_MS}ms"
echo "score:${SCORE}"
echo "metric:iops_total=${IOPS_TOTAL}"
[ -n "$READ_IOPS" ] && echo "metric:read_iops=${READ_IOPS}"
[ -n "$WRITE_IOPS" ] && echo "metric:write_iops=${WRITE_IOPS}"
[ -n "$READ_BW" ] && echo "metric:read_bw=${READ_BW}"
[ -n "$WRITE_BW" ] && echo "metric:write_bw=${WRITE_BW}"
[ -n "$READ_BW" ] && [ -n "$WRITE_BW" ] && echo "metric:bw_total=${SCORE}"
echo "output:${OUTPUT}"
# IOPS / 吞吐 曲线(按秒聚合)
BW_LOG_FILE=$(ls "$BW_LOG"*.log 2>/dev/null | head -1)
IOPS_LOG_FILE=$(ls "$IOPS_LOG"*.log 2>/dev/null | head -1)
echo "===SAMPLES==="
echo "time,iops_total,bw_total"
if [ -n "$BW_LOG_FILE" ] && [ -n "$IOPS_LOG_FILE" ]; then
awk -F',' '
FNR==NR { t=int($1/1000); if($3==0) ri[t]=$2; else wi[t]=$2; seen[t]=1; next }
{ t=int($1/1000); if($3==0) rb[t]=$2; else wb[t]=$2; seen[t]=1 }
END { for (k in seen) printf "%d,%.0f,%.1f\n", k, ri[k]+wi[k], (rb[k]+wb[k])/1024 }
' "$IOPS_LOG_FILE" "$BW_LOG_FILE" | sort -n
fi
echo "===END_SAMPLES==="
rm -f "$FIO_IMG" "$BW_LOG"*.log "$IOPS_LOG"*.log
else
# 无 fio降级为 stress-ng 磁盘 IO 压测(无 IOPS/吞吐曲线)
set +e
OUTPUT=$(stress-ng --iomix "${AUTOCHECK_THREADS:-2}" --iomix-bytes "${AUTOCHECK_DISK_MB:-512}M" --timeout "${AUTOCHECK_DURATION:-30}s" --temp-path /tmp --metrics-brief 2>&1)
EXIT_CODE=$?
set -e
SCORE=$(echo "$OUTPUT" | awk '/^stress-ng:/ && $4=="iomix" { v=$(NF-1)+0; if(v>0) s+=v } END{printf "%.1f", s+0}')
END_TIME=$(date +%s%N)
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
echo "duration:${DURATION_MS}ms"
echo "score:${SCORE}"
echo "output:${OUTPUT}"
fi
echo ''

View File

@@ -1,12 +1,71 @@
echo "===TEST:full==="
# 读取温度sensors 不可用时输出空)
read_temp() {
sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1
}
# 后台采样 CPU + 内存 + 温度
SAMPLE_LOG=/tmp/auto-check-full-sample.log
: > "$SAMPLE_LOG"
(
while true; do
TS=$(date +%H:%M:%S)
CPU=$(top -bn1 2>/dev/null | grep "Cpu(s)" | sed -n 's/.*,\s*\([0-9.]*\)%*\s*id.*/\1/p' | awk '{printf "%.1f", 100-$1}' 2>/dev/null)
MEM_TOTAL=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
MEM_AVAIL=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)
if [ -z "$MEM_AVAIL" ] || [ "$MEM_AVAIL" = "0" ]; then
MEM_AVAIL=$(awk '/^MemFree:/{print $2}' /proc/meminfo)
fi
MEM_PCT=$(awk -v t="$MEM_TOTAL" -v a="$MEM_AVAIL" 'BEGIN{if(t>0) printf "%.1f", (t-a)/t*100}')
TEMP=$(read_temp)
echo "${TS},${CPU},${MEM_PCT},${TEMP}" >> "$SAMPLE_LOG"
sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}"
done
) &
SAMPLE_PID=$!
START_TIME=$(date +%s%N)
set +e
OUTPUT=$(stress-ng --cpu "${AUTOCHECK_THREADS:-4}" --vm 2 --vm-bytes 128M --iomix 1 --iomix-bytes 256M --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1)
OUTPUT=$(stress-ng \
--cpu "${AUTOCHECK_THREADS:-4}" --cpu-method all \
--vm "${AUTOCHECK_THREADS:-4}" --vm-bytes "${AUTOCHECK_MEM_MB:-256}M" --vm-method all \
--iomix 2 --iomix-bytes "${AUTOCHECK_DISK_SIZE_MB:-512}M" \
--timeout "${AUTOCHECK_DURATION:-30}s" --temp-path /tmp --metrics-brief 2>&1)
EXIT_CODE=$?
set -e
END_TIME=$(date +%s%N)
kill $SAMPLE_PID 2>/dev/null || true
wait $SAMPLE_PID 2>/dev/null || true
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
echo "duration:${DURATION_MS}ms"
# 跑分:各 stressor 的 bogo ops/s 之和
SCORE=$(echo "$OUTPUT" | awk '
/^stress-ng:/ && ($4=="cpu" || $4=="vm" || $4=="iomix" || $4=="memrate") {
v=$(NF-1)+0; if (v>0) s+=v
}
END { printf "%.1f", s+0 }')
echo "score:${SCORE}"
# 关键指标
AVG_CPU=$(awk -F',' '$2!="" {s+=$2; n++} END{printf "%.1f", (n?s/n:0)}' "$SAMPLE_LOG")
echo "metric:avg_cpu=${AVG_CPU}"
MAX_MEM=$(awk -F',' '$3!="" && $3+0>m {m=$3} END{printf "%.1f", m+0}' "$SAMPLE_LOG")
echo "metric:mem_used_pct=${MAX_MEM}"
MAX_TEMP=$(awk -F',' '$4!="" && $4+0>m {m=$4} END{printf "%.1f", m+0}' "$SAMPLE_LOG")
HAS_TEMP=$(awk -F',' '$4!="" && $4+0>0{f=1} END{print f+0}' "$SAMPLE_LOG")
if [ "$HAS_TEMP" = "1" ]; then echo "metric:max_temp=${MAX_TEMP}"; fi
echo "output:${OUTPUT}"
# 采样数据(首行表头)
echo "===SAMPLES==="
echo "time,cpu,mem_used_pct,temp"
cat "$SAMPLE_LOG"
echo "===END_SAMPLES==="
echo ''

View File

@@ -1,14 +1,64 @@
echo "===TEST:memory==="
VM_WORKERS=${AUTOCHECK_THREADS:-4}
if [ "$VM_WORKERS" -gt 4 ]; then VM_WORKERS=4; fi
# 读取温度sensors 不可用时输出空)
read_temp() {
sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1
}
# 后台采样内存使用率 + 温度
SAMPLE_LOG=/tmp/auto-check-memory-sample.log
: > "$SAMPLE_LOG"
(
while true; do
TS=$(date +%H:%M:%S)
MEM_TOTAL=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
MEM_AVAIL=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)
if [ -z "$MEM_AVAIL" ] || [ "$MEM_AVAIL" = "0" ]; then
MEM_AVAIL=$(awk '/^MemFree:/{print $2}' /proc/meminfo)
fi
MEM_PCT=$(awk -v t="$MEM_TOTAL" -v a="$MEM_AVAIL" 'BEGIN{if(t>0) printf "%.1f", (t-a)/t*100}')
TEMP=$(read_temp)
echo "${TS},${MEM_PCT},${TEMP}" >> "$SAMPLE_LOG"
sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}"
done
) &
SAMPLE_PID=$!
START_TIME=$(date +%s%N)
set +e
OUTPUT=$(stress-ng --vm "${VM_WORKERS}" --vm-bytes 256M --vm-method all --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1)
OUTPUT=$(stress-ng --vm "${AUTOCHECK_THREADS:-4}" --vm-bytes "${AUTOCHECK_MEM_MB:-256}M" --vm-method all --timeout "${AUTOCHECK_DURATION:-30}s" --temp-path /tmp --metrics-brief 2>&1)
EXIT_CODE=$?
set -e
END_TIME=$(date +%s%N)
kill $SAMPLE_PID 2>/dev/null || true
wait $SAMPLE_PID 2>/dev/null || true
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
echo "duration:${DURATION_MS}ms"
# 跑分:各 stressor 的 bogo ops/s 之和
SCORE=$(echo "$OUTPUT" | awk '
/^stress-ng:/ && ($4=="cpu" || $4=="vm" || $4=="iomix" || $4=="memrate") {
v=$(NF-1)+0; if (v>0) s+=v
}
END { printf "%.1f", s+0 }')
echo "score:${SCORE}"
# 关键指标
MAX_MEM=$(awk -F',' '$2!="" && $2+0>m {m=$2} END{printf "%.1f", m+0}' "$SAMPLE_LOG")
echo "metric:mem_used_pct=${MAX_MEM}"
MAX_TEMP=$(awk -F',' '$3!="" && $3+0>m {m=$3} END{printf "%.1f", m+0}' "$SAMPLE_LOG")
HAS_TEMP=$(awk -F',' '$3!="" && $3+0>0{f=1} END{print f+0}' "$SAMPLE_LOG")
if [ "$HAS_TEMP" = "1" ]; then echo "metric:max_temp=${MAX_TEMP}"; fi
echo "output:${OUTPUT}"
# 采样数据(首行表头)
echo "===SAMPLES==="
echo "time,mem_used_pct,temp"
cat "$SAMPLE_LOG"
echo "===END_SAMPLES==="
echo ''

View File

@@ -1,12 +1,15 @@
echo '===MONITOR:temp==='
if [ -f "$TEMP_LOG" ] && [ -s "$TEMP_LOG" ]; then
MAX_T=$(sort -n "$TEMP_LOG" | tail -1)
AVG_T=$(awk '{s+=$1; n++} END{printf "%.1f", (n?s/n:0)}' "$TEMP_LOG")
CNT=$(wc -l < "$TEMP_LOG")
echo 'status:pass'
TEMP_MAX=$(grep -oP '\d+\.?\d*°C' "$TEMP_LOG" | sort -t. -k1 -n | tail -1 || echo 'unknown')
echo "samples:$(wc -l < "$TEMP_LOG")"
echo "max:${TEMP_MAX}"
echo "output:$(tail -10 "$TEMP_LOG")"
echo "metric:max_temp=${MAX_T}"
echo "metric:avg_temp=${AVG_T}"
echo "output:采样 ${CNT} 次, 最高 ${MAX_T}°C, 平均 ${AVG_T}°C"
else
echo 'status:skip'
echo 'output:未采集到温度数据'
fi
echo ''
kill $TEMP_PID 2>/dev/null || true

View File

@@ -1,4 +1,8 @@
TEMP_LOG=/tmp/auto-check-temp.log
> "$TEMP_LOG"
(while true; do sensors 2>/dev/null | grep -i 'temp\|core\|cpu' | head -5 >> "$TEMP_LOG"; sleep "${AUTOCHECK_TEMP_INTERVAL:-10}"; done) &
(while true; do
T=$(sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1)
[ -n "$T" ] && echo "$T" >> "$TEMP_LOG"
sleep "${AUTOCHECK_TEMP_INTERVAL:-10}"
done) &
TEMP_PID=$!