0813
This commit is contained in:
@@ -16,13 +16,16 @@ ssh:
|
|||||||
|
|
||||||
# --- 压力测试 ---
|
# --- 压力测试 ---
|
||||||
stress:
|
stress:
|
||||||
types: "cpu,memory"
|
types: "cpu,memory,disk,full" # 单项在前、综合(full)最后
|
||||||
duration: 30s
|
duration: 30s
|
||||||
threads: 4
|
threads: 4
|
||||||
|
temp_limit: 90 # 温度上限(°C),超过判失败,0=不判定
|
||||||
|
auto_install: true # 缺工具时在线安装(apt/dnf/yum 等),需要目标机可联网
|
||||||
|
use_mirror: false # 在线安装是否切换国内镜像源(清华TUNA)。国内网络卡顿/超时再改 true
|
||||||
|
|
||||||
# --- 工作流 ---
|
# --- 工作流 ---
|
||||||
workflow:
|
workflow:
|
||||||
interval: 60s
|
interval: 180s
|
||||||
|
|
||||||
# --- 报告与打印 ---
|
# --- 报告与打印 ---
|
||||||
report:
|
report:
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
|
golang.org/x/text v0.41.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
golang.org/x/sys v0.47.0 // 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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -38,13 +38,17 @@ type SSHConfig struct {
|
|||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
KeyFile string `yaml:"key"`
|
KeyFile string `yaml:"key"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
|
SudoPwd string `yaml:"sudo_password"` // 非 root 用户安装工具时使用的 sudo 密码
|
||||||
}
|
}
|
||||||
|
|
||||||
// StressConfig 压力测试
|
// StressConfig 压力测试
|
||||||
type StressConfig struct {
|
type StressConfig struct {
|
||||||
Types string `yaml:"types"`
|
Types string `yaml:"types"`
|
||||||
Duration time.Duration `yaml:"duration"`
|
Duration time.Duration `yaml:"duration"`
|
||||||
Threads int `yaml:"threads"`
|
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 报告
|
// ReportConfig 报告
|
||||||
@@ -83,9 +87,12 @@ func Default() *Config {
|
|||||||
Port: 22,
|
Port: 22,
|
||||||
},
|
},
|
||||||
Stress: StressConfig{
|
Stress: StressConfig{
|
||||||
Types: "cpu,memory",
|
Types: "cpu,memory",
|
||||||
Duration: 30 * time.Second,
|
Duration: 30 * time.Second,
|
||||||
Threads: 4,
|
Threads: 4,
|
||||||
|
TempLimit: 90,
|
||||||
|
AutoInstall: true,
|
||||||
|
UseMirror: false,
|
||||||
},
|
},
|
||||||
Workflow: WorkflowConfig{
|
Workflow: WorkflowConfig{
|
||||||
Interval: 10 * time.Second,
|
Interval: 10 * time.Second,
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ func BuildDeviceReport(dev *model.Device, rpt *stress.Report) *Report {
|
|||||||
b.AddHost(HostResult{MAC: dev.MAC, IP: dev.IP})
|
b.AddHost(HostResult{MAC: dev.MAC, IP: dev.IP})
|
||||||
for _, res := range rpt.Results {
|
for _, res := range rpt.Results {
|
||||||
b.AddStressResult(dev.MAC, StressResult{
|
b.AddStressResult(dev.MAC, StressResult{
|
||||||
Type: string(res.Type),
|
Type: string(res.Type),
|
||||||
Status: res.Status,
|
Status: res.Status,
|
||||||
Duration: res.Duration.String(),
|
Duration: res.Duration.String(),
|
||||||
Error: res.Error,
|
Score: res.Score,
|
||||||
Output: res.Output,
|
ScoreUnit: stress.ScoreUnit(res.Type),
|
||||||
|
Metrics: res.OrderedMetrics(),
|
||||||
|
Error: res.Error,
|
||||||
|
Output: res.Output,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
b.EndPhase(rpt.Summary())
|
b.EndPhase(rpt.Summary())
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"auto-check/pkg/stress"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PhaseResult 单阶段结果
|
// PhaseResult 单阶段结果
|
||||||
@@ -19,23 +21,25 @@ type PhaseResult struct {
|
|||||||
|
|
||||||
// HostResult 单台设备结果(MAC 为唯一标识)
|
// HostResult 单台设备结果(MAC 为唯一标识)
|
||||||
type HostResult struct {
|
type HostResult struct {
|
||||||
MAC string `json:"mac" yaml:"mac"`
|
MAC string `json:"mac" yaml:"mac"`
|
||||||
IP string `json:"ip" yaml:"ip"`
|
IP string `json:"ip" yaml:"ip"`
|
||||||
Alive bool `json:"alive" yaml:"alive"`
|
Alive bool `json:"alive" yaml:"alive"`
|
||||||
OpenPort int `json:"open_port,omitempty" yaml:"open_port,omitempty"`
|
OpenPort int `json:"open_port,omitempty" yaml:"open_port,omitempty"`
|
||||||
SSHLogin bool `json:"ssh_login" yaml:"ssh_login"`
|
SSHLogin bool `json:"ssh_login" yaml:"ssh_login"`
|
||||||
SSHErr string `json:"ssh_error,omitempty" yaml:"ssh_error,omitempty"`
|
SSHErr string `json:"ssh_error,omitempty" yaml:"ssh_error,omitempty"`
|
||||||
Stress []StressResult `json:"stress,omitempty" yaml:"stress,omitempty"`
|
Stress []StressResult `json:"stress,omitempty" yaml:"stress,omitempty"`
|
||||||
Curves map[string][]float64 `json:"curves,omitempty" yaml:"curves,omitempty"` // 指标名 → 时间序列(渲染为 sparkline)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StressResult 单项压力测试结果
|
// StressResult 单项压力测试结果
|
||||||
type StressResult struct {
|
type StressResult struct {
|
||||||
Type string `json:"type" yaml:"type"`
|
Type string `json:"type" yaml:"type"`
|
||||||
Status string `json:"status" yaml:"status"` // pass / fail / skip
|
Status string `json:"status" yaml:"status"` // pass / fail / skip
|
||||||
Duration string `json:"duration" yaml:"duration"`
|
Duration string `json:"duration" yaml:"duration"`
|
||||||
Output string `json:"output,omitempty" yaml:"output,omitempty"`
|
Score float64 `json:"score,omitempty" yaml:"score,omitempty"`
|
||||||
Error string `json:"error,omitempty" yaml:"error,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 检测报告
|
// Report 检测报告
|
||||||
@@ -209,6 +213,12 @@ func (r *Report) ToText() string {
|
|||||||
icon = "✗"
|
icon = "✗"
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf(" │ %s %-10s 耗时: %s\n", icon, s.Type, s.Duration))
|
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 != "" {
|
if s.Error != "" {
|
||||||
sb.WriteString(fmt.Sprintf(" │ 错误: %s\n", s.Error))
|
sb.WriteString(fmt.Sprintf(" │ 错误: %s\n", s.Error))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package report
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"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) },
|
"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)
|
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
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 "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 }}
|
{{ 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 }}
|
{{ 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 }}
|
{{ if .Score }} 跑分: {{ printf "%.1f" .Score }} {{ .ScoreUnit }}{{ end }}{{ range .Metrics }} {{ .Label }}: {{ printf "%.1f" .Value }}{{ .Unit }}
|
||||||
{{ sparkline $data 38 }}
|
{{ end }}{{ if .Error }} ERR: {{ trunc .Error 34 }}{{ end }}{{ end }}{{ end }}{{ end }}{{ end }}
|
||||||
{{ end }}{{ end }}{{ end }}{{ end }}
|
|
||||||
{{ "" }}
|
{{ "" }}
|
||||||
{{ hr '═' }}
|
{{ hr '═' }}
|
||||||
报告结束
|
报告结束
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -16,6 +17,7 @@ type Config struct {
|
|||||||
KeyFile string
|
KeyFile string
|
||||||
Port int
|
Port int
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
|
SudoPwd string // 非 root 用户执行特权命令(sudo)时的密码
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig 创建配置
|
// NewConfig 创建配置
|
||||||
@@ -54,9 +56,61 @@ func RunCommand(client *ssh.Client, command string) (string, error) {
|
|||||||
|
|
||||||
out, err := session.CombinedOutput(command)
|
out, err := session.CombinedOutput(command)
|
||||||
if err != nil {
|
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 构建认证配置
|
// buildSSHConfig 构建认证配置
|
||||||
|
|||||||
@@ -5,127 +5,264 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// chartData 给 JS 用的数据
|
// metricSpec 单条曲线规格
|
||||||
type chartData struct {
|
type metricSpec struct {
|
||||||
Labels []string `json:"labels"`
|
Key string `json:"key"`
|
||||||
CPU []float64 `json:"cpu"`
|
Label string `json:"label"`
|
||||||
Temp []float64 `json:"temp"`
|
Color string `json:"color"`
|
||||||
Title string `json:"title"`
|
Unit string `json:"unit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteChartHTML 根据采样数据生成折线图 HTML 文件,返回文件路径
|
// chartSpec 图表规格(左轴 / 右轴)
|
||||||
func WriteChartHTML(ip string, result Result, reportDir string) (string, error) {
|
type chartSpec struct {
|
||||||
if len(result.Samples) == 0 {
|
Left []metricSpec
|
||||||
return "", nil
|
Right []metricSpec
|
||||||
}
|
LeftPct bool
|
||||||
|
LeftTitle string
|
||||||
|
RightTitle string
|
||||||
|
}
|
||||||
|
|
||||||
cd := chartData{
|
// kvPair 指标明细
|
||||||
Title: fmt.Sprintf("%s - %s 压测曲线 (%s)", ip, result.Type, result.Duration),
|
type kvPair struct {
|
||||||
}
|
Label string `json:"label"`
|
||||||
for _, s := range result.Samples {
|
Value string `json:"value"`
|
||||||
cd.Labels = append(cd.Labels, s.Time)
|
}
|
||||||
cd.CPU = append(cd.CPU, s.CPU)
|
|
||||||
cd.Temp = append(cd.Temp, s.Temp)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
html := strings.Replace(chartHTMLTemplate, "__DATA__", string(dataJSON), 1)
|
html := strings.Replace(reportHTMLTemplate, "__DATA__", string(dataJSON), 1)
|
||||||
|
|
||||||
os.MkdirAll(reportDir, 0755)
|
if err := os.MkdirAll(reportDir, 0755); err != nil {
|
||||||
filename := fmt.Sprintf("%s/chart-%s-%s.html", reportDir, result.Type, SanitizeFilename(ip))
|
return "", err
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s/report-%s.html", reportDir, SanitizeFilename(ip))
|
||||||
if err := os.WriteFile(filename, []byte(html), 0644); err != nil {
|
if err := os.WriteFile(filename, []byte(html), 0644); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filename, nil
|
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 中的特殊字符,用于文件名
|
// SanitizeFilename 清理 IP 中的特殊字符,用于文件名
|
||||||
func SanitizeFilename(ip string) string {
|
func SanitizeFilename(ip string) string {
|
||||||
return strings.ReplaceAll(ip, ":", "_")
|
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
|
package stress
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,10 +11,11 @@ import (
|
|||||||
|
|
||||||
// Metric 测试指标(定义测什么、用什么工具、怎么判结果)
|
// Metric 测试指标(定义测什么、用什么工具、怎么判结果)
|
||||||
type Metric struct {
|
type Metric struct {
|
||||||
Name string // 指标名称(cpu/memory/disk/memnative/full)
|
Name string // 指标名称(cpu/memory/disk/memnative/full)
|
||||||
Tool string // 依赖的工具(stress-ng/stressapptest)
|
Tool string // 依赖的工具(stress-ng/stressapptest/fio)
|
||||||
Enabled bool // 是否启用
|
Enabled bool // 是否启用
|
||||||
IsMonitor bool // 是否为监控指标(temp/dmesg,自动附加)
|
IsMonitor bool // 是否为监控指标(temp/dmesg,自动附加)
|
||||||
|
DisableReason string // 禁用时展示的原因
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildMetrics 根据配置和可用工具,生成要执行的指标列表
|
// BuildMetrics 根据配置和可用工具,生成要执行的指标列表
|
||||||
@@ -28,16 +30,36 @@ func BuildMetrics(types []TestType, tools ToolSet) []Metric {
|
|||||||
case TestMemory:
|
case TestMemory:
|
||||||
m.Tool = "stress-ng"
|
m.Tool = "stress-ng"
|
||||||
case TestDiskIO:
|
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:
|
case TestMemNative:
|
||||||
m.Tool = "stressapptest"
|
m.Tool = "stressapptest"
|
||||||
case TestFull:
|
case TestFull:
|
||||||
m.Tool = "stress-ng"
|
m.Tool = "stress-ng"
|
||||||
default:
|
default:
|
||||||
m.Enabled = false
|
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)
|
metrics = append(metrics, m)
|
||||||
}
|
}
|
||||||
@@ -61,6 +83,7 @@ type Config struct {
|
|||||||
DiskSizeMB int // 0=默认1024
|
DiskSizeMB int // 0=默认1024
|
||||||
DiskDir string // 空=自动临时目录
|
DiskDir string // 空=自动临时目录
|
||||||
TempLogInt time.Duration // 0=10s
|
TempLogInt time.Duration // 0=10s
|
||||||
|
TempLimit float64 // 温度上限(°C),0=不判定
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig 默认配置
|
// DefaultConfig 默认配置
|
||||||
@@ -70,5 +93,6 @@ func DefaultConfig() Config {
|
|||||||
Threads: 4,
|
Threads: 4,
|
||||||
DiskSizeMB: 1024,
|
DiskSizeMB: 1024,
|
||||||
TempLogInt: 10 * time.Second,
|
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"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,18 +25,20 @@ var Results = make(map[string]*Report)
|
|||||||
|
|
||||||
// Runner 压力测试执行器
|
// Runner 压力测试执行器
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
types []TestType
|
types []TestType
|
||||||
client *ssh.Client
|
client *ssh.Client
|
||||||
tools ToolSet
|
tools ToolSet
|
||||||
metrics []Metric
|
metrics []Metric
|
||||||
|
installFailed []string // 自动安装仍失败的工具
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner 创建执行器(检测工具 → 构建指标 → 生成脚本 → 执行)
|
// NewRunner 创建执行器(检查/在线安装 → 构建指标 → 生成脚本 → 执行)
|
||||||
func NewRunner(cfg Config, types []TestType, client *ssh.Client) *Runner {
|
func NewRunner(cfg Config, types []TestType, client *ssh.Client, autoInstall bool, sudoPwd string) *Runner {
|
||||||
tools := DetectTools(client)
|
// 1. 检测工具,缺失时按发行版在线安装(可配置国内镜像源加速)
|
||||||
|
tools, failed := EnsureTools(client, requiredTools, autoInstall, sudoPwd)
|
||||||
metrics := BuildMetrics(types, tools)
|
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 执行压力测试(生成脚本 → 远程执行 → 解析结果)
|
// Run 执行压力测试(生成脚本 → 远程执行 → 解析结果)
|
||||||
@@ -49,8 +52,11 @@ func (r *Runner) Run(ip string) *Report {
|
|||||||
fmt.Printf("%s(%s) ", name, info.Version)
|
fmt.Printf("%s(%s) ", name, info.Version)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
if len(r.installFailed) > 0 {
|
||||||
|
fmt.Printf(" [安装] 以下工具自动安装失败,相关测试将跳过: %v\n", r.installFailed)
|
||||||
|
}
|
||||||
|
|
||||||
// 显示将执行的指标
|
// 显示将执行的指标及被跳过的原因
|
||||||
fmt.Printf(" 指标: ")
|
fmt.Printf(" 指标: ")
|
||||||
for _, m := range r.metrics {
|
for _, m := range r.metrics {
|
||||||
if m.Enabled {
|
if m.Enabled {
|
||||||
@@ -58,6 +64,11 @@ func (r *Runner) Run(ip string) *Report {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
for _, m := range r.metrics {
|
||||||
|
if !m.Enabled && !m.IsMonitor {
|
||||||
|
fmt.Printf(" [跳过] %s: %s\n", m.Name, m.DisableReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 按需读取各测试脚本,拼装成完整脚本
|
// 按需读取各测试脚本,拼装成完整脚本
|
||||||
script := r.assembleScript()
|
script := r.assembleScript()
|
||||||
@@ -76,20 +87,19 @@ func (r *Runner) Run(ip string) *Report {
|
|||||||
r.parseResults(report, output)
|
r.parseResults(report, output)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成采样折线图
|
report.EndTime = time.Now()
|
||||||
for _, res := range report.Results {
|
report.Duration = report.EndTime.Sub(report.StartTime)
|
||||||
if len(res.Samples) > 0 {
|
|
||||||
chartPath, err := WriteChartHTML(ip, res, "reports")
|
// 生成综合 HTML 报告(跑分卡片 + 曲线图)
|
||||||
if err != nil {
|
if len(report.Results) > 0 {
|
||||||
fmt.Printf(" [图表] 生成失败: %v\n", err)
|
reportPath, err := WriteReportHTML(ip, report, "reports")
|
||||||
} else {
|
if err != nil {
|
||||||
fmt.Printf(" [图表] %s\n", chartPath)
|
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.Printf("\n════════ [%s] 压力测试完成 ════════\n", ip)
|
||||||
fmt.Println(report.ToText())
|
fmt.Println(report.ToText())
|
||||||
return report
|
return report
|
||||||
@@ -103,7 +113,11 @@ func (r *Runner) parseResults(report *Report, output string) {
|
|||||||
var currentDuration string
|
var currentDuration string
|
||||||
var currentOutput []string
|
var currentOutput []string
|
||||||
var currentSamples []Sample
|
var currentSamples []Sample
|
||||||
|
var currentScore float64
|
||||||
|
var currentMetrics map[string]float64
|
||||||
|
var sampleHeader []string
|
||||||
inSamples := false
|
inSamples := false
|
||||||
|
inSysInfo := false
|
||||||
|
|
||||||
flush := func() {
|
flush := func() {
|
||||||
if currentTest == "" {
|
if currentTest == "" {
|
||||||
@@ -118,6 +132,14 @@ func (r *Runner) parseResults(report *Report, output string) {
|
|||||||
case "error":
|
case "error":
|
||||||
status = StatusError
|
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)
|
duration, _ := time.ParseDuration(currentDuration)
|
||||||
report.AddResult(Result{
|
report.AddResult(Result{
|
||||||
Type: TestType(currentTest),
|
Type: TestType(currentTest),
|
||||||
@@ -125,12 +147,17 @@ func (r *Runner) parseResults(report *Report, output string) {
|
|||||||
Output: strings.Join(currentOutput, "\n"),
|
Output: strings.Join(currentOutput, "\n"),
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
Samples: currentSamples,
|
Samples: currentSamples,
|
||||||
|
Score: currentScore,
|
||||||
|
Metrics: currentMetrics,
|
||||||
})
|
})
|
||||||
currentTest = ""
|
currentTest = ""
|
||||||
currentStatus = ""
|
currentStatus = ""
|
||||||
currentDuration = ""
|
currentDuration = ""
|
||||||
currentOutput = nil
|
currentOutput = nil
|
||||||
currentSamples = nil
|
currentSamples = nil
|
||||||
|
currentScore = 0
|
||||||
|
currentMetrics = nil
|
||||||
|
sampleHeader = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
@@ -139,6 +166,7 @@ func (r *Runner) parseResults(report *Report, output string) {
|
|||||||
// 采样数据块
|
// 采样数据块
|
||||||
if line == "===SAMPLES===" {
|
if line == "===SAMPLES===" {
|
||||||
inSamples = true
|
inSamples = true
|
||||||
|
sampleHeader = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if line == "===END_SAMPLES===" {
|
if line == "===END_SAMPLES===" {
|
||||||
@@ -147,14 +175,46 @@ func (r *Runner) parseResults(report *Report, output string) {
|
|||||||
}
|
}
|
||||||
if inSamples {
|
if inSamples {
|
||||||
parts := strings.Split(line, ",")
|
parts := strings.Split(line, ",")
|
||||||
if len(parts) >= 3 {
|
if sampleHeader == nil {
|
||||||
cpu, _ := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
// 首行为表头:time,指标1,指标2,...
|
||||||
temp, _ := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
|
for _, p := range parts {
|
||||||
currentSamples = append(currentSamples, Sample{
|
sampleHeader = append(sampleHeader, strings.TrimSpace(p))
|
||||||
Time: strings.TrimSpace(parts[0]),
|
}
|
||||||
CPU: cpu,
|
continue
|
||||||
Temp: temp,
|
}
|
||||||
})
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -187,12 +247,24 @@ func (r *Runner) parseResults(report *Report, output string) {
|
|||||||
currentDuration = fmt.Sprintf("%dms", ms)
|
currentDuration = fmt.Sprintf("%dms", ms)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "output:") {
|
if strings.HasPrefix(line, "score:") {
|
||||||
currentOutput = append(currentOutput, strings.TrimPrefix(line, "output:"))
|
currentScore, _ = strconv.ParseFloat(strings.TrimPrefix(line, "score:"), 64)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "samples:") || strings.HasPrefix(line, "max:") {
|
if strings.HasPrefix(line, "metric:") {
|
||||||
currentOutput = append(currentOutput, line)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,13 +274,6 @@ func (r *Runner) parseResults(report *Report, output string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush()
|
flush()
|
||||||
|
|
||||||
// 从系统信息输出解析主机信息
|
|
||||||
for _, line := range lines {
|
|
||||||
if strings.HasPrefix(line, "hostname:") {
|
|
||||||
// 可扩展:存入 report 的系统信息字段
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assembleScript 根据 metrics 读取对应的独立脚本文件,拼装成完整脚本
|
// assembleScript 根据 metrics 读取对应的独立脚本文件,拼装成完整脚本
|
||||||
@@ -248,9 +313,24 @@ func (r *Runner) assembleScript() string {
|
|||||||
sb.WriteString("\n")
|
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 {
|
for _, m := range ordered {
|
||||||
if m.IsMonitor || !m.Enabled {
|
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
|
continue
|
||||||
}
|
}
|
||||||
data := r.readScript(m.Name)
|
data := r.readScript(m.Name)
|
||||||
@@ -286,6 +366,27 @@ func (r *Runner) readScript(name string) []byte {
|
|||||||
return data
|
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 调用)
|
// 业务入口(workflow 调用)
|
||||||
// ============================
|
// ============================
|
||||||
@@ -293,6 +394,7 @@ func (r *Runner) readScript(name string) []byte {
|
|||||||
// TestDevice 对单台设备执行 SSH 登录 + 压力测试(业务入口)
|
// TestDevice 对单台设备执行 SSH 登录 + 压力测试(业务入口)
|
||||||
func TestDevice(ip string, cfg config.Config) *Report {
|
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 := 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)
|
conn, err := sshclient.Connect(ip, sshCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -300,6 +402,19 @@ func TestDevice(ip string, cfg config.Config) *Report {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
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)
|
// SSH 登录成功后回填远端 MAC(发现阶段只做了 ping,暂无 MAC)
|
||||||
if mac := queryRemoteMAC(conn); mac != "" {
|
if mac := queryRemoteMAC(conn); mac != "" {
|
||||||
if dev, ok := discovery.Devices[ip]; ok {
|
if dev, ok := discovery.Devices[ip]; ok {
|
||||||
@@ -314,8 +429,9 @@ func TestDevice(ip string, cfg config.Config) *Report {
|
|||||||
Threads: cfg.Stress.Threads,
|
Threads: cfg.Stress.Threads,
|
||||||
DiskSizeMB: 1024,
|
DiskSizeMB: 1024,
|
||||||
TempLogInt: 10 * time.Second,
|
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 连接失败报告
|
// NewSSHFailReport 构造 SSH 连接失败报告
|
||||||
@@ -365,6 +481,15 @@ func IsPassed(rpt *Report) bool {
|
|||||||
return rpt != nil && rpt.Failed == 0 && rpt.Errors == 0
|
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 报告状态字符串
|
// Status 报告状态字符串
|
||||||
func Status(rpt *Report) string {
|
func Status(rpt *Report) string {
|
||||||
if rpt == nil {
|
if rpt == nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package stress
|
package stress
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"auto-check/pkg/sshclient"
|
"auto-check/pkg/sshclient"
|
||||||
@@ -8,10 +9,82 @@ import (
|
|||||||
"golang.org/x/crypto/ssh"
|
"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 远程工具信息
|
// ToolInfo 远程工具信息
|
||||||
type ToolInfo struct {
|
type ToolInfo struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -47,31 +120,185 @@ func DetectTools(client *ssh.Client) ToolSet {
|
|||||||
return ts
|
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) {
|
func detectOne(client *ssh.Client, name string) (ToolInfo, bool) {
|
||||||
out, err := sshclient.RunCommand(client, "which "+name+" 2>/dev/null")
|
path := ""
|
||||||
path := strings.TrimSpace(out)
|
if out, err := sshclient.RunCommand(client, "which "+name+" 2>/dev/null"); err == nil {
|
||||||
if err != nil || path == "" {
|
path = strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
return ToolInfo{}, false
|
return ToolInfo{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
var version string
|
var version string
|
||||||
switch name {
|
switch name {
|
||||||
case "stress-ng":
|
case "stress-ng":
|
||||||
out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||||
version = strings.TrimSpace(out)
|
version = strings.TrimSpace(v)
|
||||||
case "stressapptest":
|
case "stressapptest":
|
||||||
out, _ = sshclient.RunCommand(client, path+" --help 2>&1 | head -1")
|
v, _ := sshclient.RunCommand(client, path+" --help 2>&1 | head -1")
|
||||||
version = strings.TrimSpace(out)
|
version = strings.TrimSpace(v)
|
||||||
case "iperf3":
|
case "iperf3":
|
||||||
out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||||
version = strings.TrimSpace(out)
|
version = strings.TrimSpace(v)
|
||||||
case "fio":
|
case "fio":
|
||||||
out, _ = sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
v, _ := sshclient.RunCommand(client, path+" --version 2>&1 | head -1")
|
||||||
version = strings.TrimSpace(out)
|
version = strings.TrimSpace(v)
|
||||||
case "lm-sensors":
|
case "lm-sensors":
|
||||||
out, _ = sshclient.RunCommand(client, path+" -v 2>&1 | head -1")
|
v, _ := sshclient.RunCommand(client, path+" -v 2>&1 | head -1")
|
||||||
version = strings.TrimSpace(out)
|
version = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ToolInfo{Name: name, Path: path, Version: version}, true
|
return ToolInfo{Name: name, Path: path, Version: version}, true
|
||||||
|
|||||||
@@ -35,14 +35,128 @@ type Result struct {
|
|||||||
Output string
|
Output string
|
||||||
Error string
|
Error string
|
||||||
Duration time.Duration
|
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 {
|
type Sample struct {
|
||||||
Time string // HH:MM:SS
|
Time string // 时间(HH:MM:SS 或相对秒)
|
||||||
CPU float64 // CPU 使用率 %
|
Values map[string]float64 // 指标名 → 值
|
||||||
Temp float64 // 温度 °C
|
}
|
||||||
|
|
||||||
|
// 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 完整压测报告
|
// Report 完整压测报告
|
||||||
@@ -52,6 +166,7 @@ type Report struct {
|
|||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Results []Result
|
Results []Result
|
||||||
|
SysInfo map[string]string // 系统信息(hostname/cpu/cores/memory/kernel/disk)
|
||||||
Passed int
|
Passed int
|
||||||
Failed int
|
Failed int
|
||||||
Skipped int
|
Skipped int
|
||||||
@@ -96,6 +211,13 @@ func (r *Report) ToText() string {
|
|||||||
icon = "⚠"
|
icon = "⚠"
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf(" %s %-12s %s\n", icon, res.Type, res.Status))
|
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") {
|
for _, line := range strings.Split(res.Output, "\n") {
|
||||||
if strings.TrimSpace(line) != "" {
|
if strings.TrimSpace(line) != "" {
|
||||||
sb.WriteString(fmt.Sprintf(" %s\n", line))
|
sb.WriteString(fmt.Sprintf(" %s\n", line))
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ func (w *Workflow) round() {
|
|||||||
fmt.Printf(" [测试] %s (%s) 开始压测...\n", ip, dev.MAC)
|
fmt.Printf(" [测试] %s (%s) 开始压测...\n", ip, dev.MAC)
|
||||||
rpt := stress.TestDevice(ip, w.cfg)
|
rpt := stress.TestDevice(ip, w.cfg)
|
||||||
stress.Results[ip] = rpt
|
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)
|
report.SaveAndPrintDeviceReport(&dev, rpt, cfg.Report.Path, cfg.Report.Print)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
auto-check/run.err
Normal file
0
auto-check/run.err
Normal file
142
auto-check/run.log
Normal file
142
auto-check/run.log
Normal 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 个IP,10 并发 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
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
echo "===TEST:cpu==="
|
echo "===TEST:cpu==="
|
||||||
|
|
||||||
|
# 读取温度(sensors 不可用时输出空)
|
||||||
|
read_temp() {
|
||||||
|
sensors 2>/dev/null | grep -oP '\+?\d+(\.\d+)?(?=°C)' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
# 后台采样 CPU 使用率 + 温度
|
# 后台采样 CPU 使用率 + 温度
|
||||||
SAMPLE_LOG=/tmp/auto-check-cpu-sample.log
|
SAMPLE_LOG=/tmp/auto-check-cpu-sample.log
|
||||||
> "$SAMPLE_LOG"
|
: > "$SAMPLE_LOG"
|
||||||
(
|
(
|
||||||
while true; do
|
while true; do
|
||||||
TS=$(date +%H:%M:%S)
|
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)
|
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=$(sensors 2>/dev/null | grep -iE 'Package|Core\s*0|temp1|CPU' | head -1 | grep -oP '\d+\.?\d*' | head -1 || echo 0)
|
TEMP=$(read_temp)
|
||||||
echo "${TS},${CPU},${TEMP}" >> "$SAMPLE_LOG"
|
echo "${TS},${CPU},${TEMP}" >> "$SAMPLE_LOG"
|
||||||
sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}"
|
sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}"
|
||||||
done
|
done
|
||||||
@@ -16,7 +21,7 @@ SAMPLE_PID=$!
|
|||||||
|
|
||||||
START_TIME=$(date +%s%N)
|
START_TIME=$(date +%s%N)
|
||||||
set +e
|
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=$?
|
EXIT_CODE=$?
|
||||||
set -e
|
set -e
|
||||||
END_TIME=$(date +%s%N)
|
END_TIME=$(date +%s%N)
|
||||||
@@ -27,10 +32,27 @@ wait $SAMPLE_PID 2>/dev/null || true
|
|||||||
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
|
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
|
||||||
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
|
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
|
||||||
echo "duration:${DURATION_MS}ms"
|
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 "output:${OUTPUT}"
|
||||||
|
|
||||||
# 输出采样数据
|
# 采样数据(首行表头)
|
||||||
echo "===SAMPLES==="
|
echo "===SAMPLES==="
|
||||||
|
echo "time,cpu,temp"
|
||||||
cat "$SAMPLE_LOG"
|
cat "$SAMPLE_LOG"
|
||||||
echo "===END_SAMPLES==="
|
echo "===END_SAMPLES==="
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,77 @@
|
|||||||
echo "===TEST:disk==="
|
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)
|
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}")
|
if command -v fio >/dev/null 2>&1; then
|
||||||
EXIT_CODE=$?
|
FIO_IMG="$TEST_DIR/fio.img"
|
||||||
set -e
|
BW_LOG="$TEST_DIR/ac-bw"
|
||||||
END_TIME=$(date +%s%N)
|
IOPS_LOG="$TEST_DIR/ac-iops"
|
||||||
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
|
rm -f "$BW_LOG"*.log "$IOPS_LOG"*.log "$FIO_IMG"
|
||||||
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
|
|
||||||
echo "duration:${DURATION_MS}ms"
|
set +e
|
||||||
echo "output:${OUTPUT}"
|
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 ''
|
echo ''
|
||||||
|
|||||||
@@ -1,12 +1,71 @@
|
|||||||
echo "===TEST:full==="
|
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)
|
START_TIME=$(date +%s%N)
|
||||||
set +e
|
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=$?
|
EXIT_CODE=$?
|
||||||
set -e
|
set -e
|
||||||
END_TIME=$(date +%s%N)
|
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 ))
|
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
|
||||||
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
|
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
|
||||||
echo "duration:${DURATION_MS}ms"
|
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 "output:${OUTPUT}"
|
||||||
|
|
||||||
|
# 采样数据(首行表头)
|
||||||
|
echo "===SAMPLES==="
|
||||||
|
echo "time,cpu,mem_used_pct,temp"
|
||||||
|
cat "$SAMPLE_LOG"
|
||||||
|
echo "===END_SAMPLES==="
|
||||||
|
|
||||||
echo ''
|
echo ''
|
||||||
|
|||||||
@@ -1,14 +1,64 @@
|
|||||||
echo "===TEST:memory==="
|
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)
|
START_TIME=$(date +%s%N)
|
||||||
set +e
|
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=$?
|
EXIT_CODE=$?
|
||||||
set -e
|
set -e
|
||||||
END_TIME=$(date +%s%N)
|
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 ))
|
DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 ))
|
||||||
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
|
if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi
|
||||||
echo "duration:${DURATION_MS}ms"
|
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 "output:${OUTPUT}"
|
||||||
|
|
||||||
|
# 采样数据(首行表头)
|
||||||
|
echo "===SAMPLES==="
|
||||||
|
echo "time,mem_used_pct,temp"
|
||||||
|
cat "$SAMPLE_LOG"
|
||||||
|
echo "===END_SAMPLES==="
|
||||||
|
|
||||||
echo ''
|
echo ''
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
echo '===MONITOR:temp==='
|
echo '===MONITOR:temp==='
|
||||||
if [ -f "$TEMP_LOG" ] && [ -s "$TEMP_LOG" ]; then
|
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'
|
echo 'status:pass'
|
||||||
TEMP_MAX=$(grep -oP '\d+\.?\d*°C' "$TEMP_LOG" | sort -t. -k1 -n | tail -1 || echo 'unknown')
|
echo "metric:max_temp=${MAX_T}"
|
||||||
echo "samples:$(wc -l < "$TEMP_LOG")"
|
echo "metric:avg_temp=${AVG_T}"
|
||||||
echo "max:${TEMP_MAX}"
|
echo "output:采样 ${CNT} 次, 最高 ${MAX_T}°C, 平均 ${AVG_T}°C"
|
||||||
echo "output:$(tail -10 "$TEMP_LOG")"
|
|
||||||
else
|
else
|
||||||
echo 'status:skip'
|
echo 'status:skip'
|
||||||
|
echo 'output:未采集到温度数据'
|
||||||
fi
|
fi
|
||||||
echo ''
|
echo ''
|
||||||
kill $TEMP_PID 2>/dev/null || true
|
kill $TEMP_PID 2>/dev/null || true
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
TEMP_LOG=/tmp/auto-check-temp.log
|
TEMP_LOG=/tmp/auto-check-temp.log
|
||||||
> "$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=$!
|
TEMP_PID=$!
|
||||||
|
|||||||
Reference in New Issue
Block a user