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

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net"
"os"
"sort"
"strconv"
"strings"
"time"
@@ -24,18 +25,20 @@ var Results = make(map[string]*Report)
// Runner 压力测试执行器
type Runner struct {
cfg Config
types []TestType
client *ssh.Client
tools ToolSet
metrics []Metric
cfg Config
types []TestType
client *ssh.Client
tools ToolSet
metrics []Metric
installFailed []string // 自动安装仍失败的工具
}
// NewRunner 创建执行器(检测工具 → 构建指标 → 生成脚本 → 执行)
func NewRunner(cfg Config, types []TestType, client *ssh.Client) *Runner {
tools := DetectTools(client)
// NewRunner 创建执行器(检查/在线安装 → 构建指标 → 生成脚本 → 执行)
func NewRunner(cfg Config, types []TestType, client *ssh.Client, autoInstall bool, sudoPwd string) *Runner {
// 1. 检测工具,缺失时按发行版在线安装(可配置国内镜像源加速)
tools, failed := EnsureTools(client, requiredTools, autoInstall, sudoPwd)
metrics := BuildMetrics(types, tools)
return &Runner{cfg: cfg, types: types, client: client, tools: tools, metrics: metrics}
return &Runner{cfg: cfg, types: types, client: client, tools: tools, metrics: metrics, installFailed: failed}
}
// Run 执行压力测试(生成脚本 → 远程执行 → 解析结果)
@@ -49,8 +52,11 @@ func (r *Runner) Run(ip string) *Report {
fmt.Printf("%s(%s) ", name, info.Version)
}
fmt.Println()
if len(r.installFailed) > 0 {
fmt.Printf(" [安装] 以下工具自动安装失败,相关测试将跳过: %v\n", r.installFailed)
}
// 显示将执行的指标
// 显示将执行的指标及被跳过的原因
fmt.Printf(" 指标: ")
for _, m := range r.metrics {
if m.Enabled {
@@ -58,6 +64,11 @@ func (r *Runner) Run(ip string) *Report {
}
}
fmt.Println()
for _, m := range r.metrics {
if !m.Enabled && !m.IsMonitor {
fmt.Printf(" [跳过] %s: %s\n", m.Name, m.DisableReason)
}
}
// 按需读取各测试脚本,拼装成完整脚本
script := r.assembleScript()
@@ -76,20 +87,19 @@ func (r *Runner) Run(ip string) *Report {
r.parseResults(report, output)
}
// 生成采样折线图
for _, res := range report.Results {
if len(res.Samples) > 0 {
chartPath, err := WriteChartHTML(ip, res, "reports")
if err != nil {
fmt.Printf(" [图表] 生成失败: %v\n", err)
} else {
fmt.Printf(" [图表] %s\n", chartPath)
}
report.EndTime = time.Now()
report.Duration = report.EndTime.Sub(report.StartTime)
// 生成综合 HTML 报告(跑分卡片 + 曲线图)
if len(report.Results) > 0 {
reportPath, err := WriteReportHTML(ip, report, "reports")
if err != nil {
fmt.Printf(" [报告] 生成失败: %v\n", err)
} else {
fmt.Printf(" [报告] %s\n", reportPath)
}
}
report.EndTime = time.Now()
report.Duration = report.EndTime.Sub(report.StartTime)
fmt.Printf("\n════════ [%s] 压力测试完成 ════════\n", ip)
fmt.Println(report.ToText())
return report
@@ -103,7 +113,11 @@ func (r *Runner) parseResults(report *Report, output string) {
var currentDuration string
var currentOutput []string
var currentSamples []Sample
var currentScore float64
var currentMetrics map[string]float64
var sampleHeader []string
inSamples := false
inSysInfo := false
flush := func() {
if currentTest == "" {
@@ -118,6 +132,14 @@ func (r *Runner) parseResults(report *Report, output string) {
case "error":
status = StatusError
}
// 温度超限判定(仅对 temp 监控)
if TestType(currentTest) == MonitorTemp && status == StatusPass && r.cfg.TempLimit > 0 {
if maxT, ok := currentMetrics["max_temp"]; ok && maxT > r.cfg.TempLimit {
status = StatusFail
currentOutput = append(currentOutput,
fmt.Sprintf("温度超限: 最高 %.1f°C > 阈值 %.1f°C", maxT, r.cfg.TempLimit))
}
}
duration, _ := time.ParseDuration(currentDuration)
report.AddResult(Result{
Type: TestType(currentTest),
@@ -125,12 +147,17 @@ func (r *Runner) parseResults(report *Report, output string) {
Output: strings.Join(currentOutput, "\n"),
Duration: duration,
Samples: currentSamples,
Score: currentScore,
Metrics: currentMetrics,
})
currentTest = ""
currentStatus = ""
currentDuration = ""
currentOutput = nil
currentSamples = nil
currentScore = 0
currentMetrics = nil
sampleHeader = nil
}
for _, line := range lines {
@@ -139,6 +166,7 @@ func (r *Runner) parseResults(report *Report, output string) {
// 采样数据块
if line == "===SAMPLES===" {
inSamples = true
sampleHeader = nil
continue
}
if line == "===END_SAMPLES===" {
@@ -147,14 +175,46 @@ func (r *Runner) parseResults(report *Report, output string) {
}
if inSamples {
parts := strings.Split(line, ",")
if len(parts) >= 3 {
cpu, _ := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
temp, _ := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
currentSamples = append(currentSamples, Sample{
Time: strings.TrimSpace(parts[0]),
CPU: cpu,
Temp: temp,
})
if sampleHeader == nil {
// 首行为表头time,指标1,指标2,...
for _, p := range parts {
sampleHeader = append(sampleHeader, strings.TrimSpace(p))
}
continue
}
if len(parts) < 1 || strings.TrimSpace(parts[0]) == "" {
continue
}
s := Sample{Time: strings.TrimSpace(parts[0]), Values: map[string]float64{}}
for i := 1; i < len(parts) && i < len(sampleHeader); i++ {
name := sampleHeader[i]
if name == "" || name == "time" {
continue
}
if v, err := strconv.ParseFloat(strings.TrimSpace(parts[i]), 64); err == nil {
s.Values[name] = v
}
}
currentSamples = append(currentSamples, s)
continue
}
if line == "===SYSTEM_INFO===" {
inSysInfo = true
continue
}
if inSysInfo {
if line == "" {
inSysInfo = false
continue
}
if i := strings.Index(line, ":"); i > 0 {
k := strings.TrimSpace(line[:i])
v := strings.TrimSpace(line[i+1:])
if report.SysInfo == nil {
report.SysInfo = map[string]string{}
}
report.SysInfo[k] = v
}
continue
}
@@ -187,12 +247,24 @@ func (r *Runner) parseResults(report *Report, output string) {
currentDuration = fmt.Sprintf("%dms", ms)
continue
}
if strings.HasPrefix(line, "output:") {
currentOutput = append(currentOutput, strings.TrimPrefix(line, "output:"))
if strings.HasPrefix(line, "score:") {
currentScore, _ = strconv.ParseFloat(strings.TrimPrefix(line, "score:"), 64)
continue
}
if strings.HasPrefix(line, "samples:") || strings.HasPrefix(line, "max:") {
currentOutput = append(currentOutput, line)
if strings.HasPrefix(line, "metric:") {
kv := strings.TrimPrefix(line, "metric:")
if i := strings.Index(kv, "="); i > 0 {
name := strings.TrimSpace(kv[:i])
val, _ := strconv.ParseFloat(strings.TrimSpace(kv[i+1:]), 64)
if currentMetrics == nil {
currentMetrics = map[string]float64{}
}
currentMetrics[name] = val
}
continue
}
if strings.HasPrefix(line, "output:") {
currentOutput = append(currentOutput, strings.TrimPrefix(line, "output:"))
continue
}
@@ -202,13 +274,6 @@ func (r *Runner) parseResults(report *Report, output string) {
}
}
flush()
// 从系统信息输出解析主机信息
for _, line := range lines {
if strings.HasPrefix(line, "hostname:") {
// 可扩展:存入 report 的系统信息字段
}
}
}
// assembleScript 根据 metrics 读取对应的独立脚本文件,拼装成完整脚本
@@ -248,9 +313,24 @@ func (r *Runner) assembleScript() string {
sb.WriteString("\n")
}
// 按"先单项、后综合"排序测试脚本
ordered := make([]Metric, len(r.metrics))
copy(ordered, r.metrics)
sort.SliceStable(ordered, func(i, j int) bool {
return testPriority(ordered[i]) < testPriority(ordered[j])
})
// 逐个测试脚本
for _, m := range r.metrics {
if m.IsMonitor || !m.Enabled {
for _, m := range ordered {
if m.IsMonitor {
continue
}
if !m.Enabled {
// 缺少依赖时仍输出一个 skip 结果,让报告完整展示原因
sb.WriteString(fmt.Sprintf("echo '===TEST:%s==='\n", m.Name))
sb.WriteString("echo 'status:skip'\n")
sb.WriteString(fmt.Sprintf("echo 'output:%s'\n", m.DisableReason))
sb.WriteString("echo ''\n")
continue
}
data := r.readScript(m.Name)
@@ -286,6 +366,27 @@ func (r *Runner) readScript(name string) []byte {
return data
}
// testPriority 测试执行顺序:单项测试在前,综合测试(full)最后,监控项最后
func testPriority(m Metric) int {
if m.IsMonitor {
return 1000
}
switch TestType(m.Name) {
case TestCPU:
return 0
case TestMemory:
return 1
case TestDiskIO:
return 2
case TestMemNative:
return 3
case TestFull:
return 100
default:
return 50
}
}
// ============================
// 业务入口workflow 调用)
// ============================
@@ -293,6 +394,7 @@ func (r *Runner) readScript(name string) []byte {
// TestDevice 对单台设备执行 SSH 登录 + 压力测试(业务入口)
func TestDevice(ip string, cfg config.Config) *Report {
sshCfg := sshclient.NewConfig(cfg.SSH.User, cfg.SSH.Password, cfg.SSH.KeyFile, cfg.SSH.Port, cfg.Scan.Timeout)
sshCfg.SudoPwd = cfg.SSH.SudoPwd
conn, err := sshclient.Connect(ip, sshCfg)
if err != nil {
@@ -300,6 +402,19 @@ func TestDevice(ip string, cfg config.Config) *Report {
}
defer conn.Close()
// fnOS 的 admin 用户 HOME 目录可能不存在,登录会输出
// "Could not chdir to home directory" 横幅污染命令输出,先建好 HOME
sshclient.EnsureHome(conn)
// 根据配置决定是否在线安装时使用国内镜像源(默认 false先试系统自带源
SetMirror(cfg.Stress.UseMirror)
// sudo 密码:优先用专属 sudo_password为空时退回 SSH 登录密码(飞牛 admin 同密码)
sudoPwd := cfg.SSH.SudoPwd
if sudoPwd == "" {
sudoPwd = cfg.SSH.Password
}
// SSH 登录成功后回填远端 MAC发现阶段只做了 ping暂无 MAC
if mac := queryRemoteMAC(conn); mac != "" {
if dev, ok := discovery.Devices[ip]; ok {
@@ -314,8 +429,9 @@ func TestDevice(ip string, cfg config.Config) *Report {
Threads: cfg.Stress.Threads,
DiskSizeMB: 1024,
TempLogInt: 10 * time.Second,
TempLimit: cfg.Stress.TempLimit,
}
return NewRunner(stressCfg, ParseTypes(cfg.Stress.Types), conn).Run(ip)
return NewRunner(stressCfg, ParseTypes(cfg.Stress.Types), conn, cfg.Stress.AutoInstall, sudoPwd).Run(ip)
}
// NewSSHFailReport 构造 SSH 连接失败报告
@@ -365,6 +481,15 @@ func IsPassed(rpt *Report) bool {
return rpt != nil && rpt.Failed == 0 && rpt.Errors == 0
}
// IsSSHFail 报告是否为 SSH 连接失败(连不上,无任何有效测试项)
func IsSSHFail(rpt *Report) bool {
if rpt == nil || len(rpt.Results) != 1 {
return false
}
r := rpt.Results[0]
return r.Type == "ssh" && (r.Status == StatusFail || r.Status == StatusError)
}
// Status 报告状态字符串
func Status(rpt *Report) string {
if rpt == nil {