diff --git a/.gitignore b/.gitignore index a95af35..f63fe88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ .reasonix/ + +.idea/ +.vscode/ + diff --git a/auto-check/auto-check.yaml b/auto-check/auto-check.yaml index f188ce4..c3b1ebe 100644 --- a/auto-check/auto-check.yaml +++ b/auto-check/auto-check.yaml @@ -3,13 +3,14 @@ # --- 网段扫描 --- scan: - cidr: "192.168.1.0/24" + cidr: "10.0.3.0/24" timeout: 3s + concurrency: 30 # --- SSH 认证 --- ssh: - user: "root" - password: "" + user: "admin" + password: "admin123" key: "" port: 22 @@ -21,7 +22,7 @@ stress: # --- 工作流 --- workflow: - interval: 10s + interval: 30s # --- 报告与打印 --- report: diff --git a/auto-check/pkg/config/config.go b/auto-check/pkg/config/config.go index 1cd9c47..a52540f 100644 --- a/auto-check/pkg/config/config.go +++ b/auto-check/pkg/config/config.go @@ -27,8 +27,9 @@ type Config struct { // ScanConfig 网段扫描 type ScanConfig struct { - CIDR string `yaml:"cidr"` - Timeout time.Duration `yaml:"timeout"` + CIDR string `yaml:"cidr"` + Timeout time.Duration `yaml:"timeout"` + Concurrency int `yaml:"concurrency"` // 并发探测数,默认 30 } // SSHConfig SSH 认证 @@ -73,8 +74,9 @@ type WorkflowConfig struct { func Default() *Config { return &Config{ Scan: ScanConfig{ - CIDR: "192.168.1.0/24", - Timeout: 3 * time.Second, + CIDR: "192.168.1.0/24", + Timeout: 3 * time.Second, + Concurrency: 30, }, SSH: SSHConfig{ User: "root", diff --git a/auto-check/pkg/discovery/arp.go b/auto-check/pkg/discovery/arp.go deleted file mode 100644 index 75593d1..0000000 --- a/auto-check/pkg/discovery/arp.go +++ /dev/null @@ -1,77 +0,0 @@ -package discovery - -import ( - "os" - "os/exec" - "runtime" - "strings" -) - -// lookupMAC 从 ARP 表获取 IP 对应的 MAC 地址(跨平台) -// 返回空字符串表示无法获取 -func lookupMAC(ip string) string { - switch runtime.GOOS { - case "linux": - return macFromProcNetArp(ip) - case "windows": - return macFromWindowsArp(ip) - default: - return macFromUnixArp(ip) - } -} - -// macFromProcNetArp 解析 Linux /proc/net/arp -func macFromProcNetArp(ip string) string { - data, err := os.ReadFile("/proc/net/arp") - if err != nil { - return "" - } - for _, line := range strings.Split(string(data), "\n")[1:] { - fields := strings.Fields(line) - if len(fields) >= 4 && fields[0] == ip { - mac := fields[3] - if mac != "" && mac != "00:00:00:00:00:00" { - return mac - } - } - } - return "" -} - -// macFromWindowsArp 解析 Windows `arp -a` 输出 -func macFromWindowsArp(ip string) string { - out, err := exec.Command("arp", "-a").Output() - if err != nil { - return "" - } - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(line) - if len(fields) >= 2 && fields[0] == ip { - mac := strings.ReplaceAll(fields[1], "-", ":") - if mac != "" && mac != "00:00:00:00:00:00" { - return strings.ToLower(mac) - } - } - } - return "" -} - -// macFromUnixArp 解析 macOS/Linux `arp -n` 输出 -func macFromUnixArp(ip string) string { - out, err := exec.Command("arp", "-n", ip).Output() - if err != nil { - return "" - } - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(line) - for i, f := range fields { - if f == "("+ip+")" && i+2 < len(fields) { - mac := fields[i+2] - if mac != "" && mac != "ff:ff:ff:ff:ff:ff" { - return strings.ToLower(mac) - } - } - } - } - return "" -} diff --git a/auto-check/pkg/discovery/discover.go b/auto-check/pkg/discovery/discover.go index 021b805..9086e85 100644 --- a/auto-check/pkg/discovery/discover.go +++ b/auto-check/pkg/discovery/discover.go @@ -1,23 +1,18 @@ package discovery import ( - "fmt" - "auto-check/pkg/model" ) // Devices 设备表(IP → 设备),每轮 Discover() 后更新,包级公开 var Devices = make(map[string]model.Device) -// Discover 扫描并更新包级 Devices map(过滤无 MAC 设备,保留上次状态延续) +// Discover 扫描并更新包级 Devices map func (s *Scanner) Discover() { scanned := s.Scan() - Devices = make(map[string]model.Device, len(scanned)) + devices := make(map[string]model.Device, len(scanned)) for _, dev := range scanned { - if dev.MAC == "" { - fmt.Printf(" [!] %s 无 MAC(ARP 未解析),下轮重试\n", dev.IP) - continue - } - Devices[dev.IP] = dev + devices[dev.IP] = dev } + Devices = devices } diff --git a/auto-check/pkg/discovery/scanner.go b/auto-check/pkg/discovery/scanner.go index 2b548b0..c874529 100644 --- a/auto-check/pkg/discovery/scanner.go +++ b/auto-check/pkg/discovery/scanner.go @@ -1,61 +1,88 @@ package discovery import ( + "context" "fmt" "net" "os/exec" - "runtime" + "strings" + "sync" "time" "auto-check/pkg/model" ) -// Scanner 网段扫描器(串行,按顺序逐个探测) +// Scanner 网段扫描器(并发 ping 探测) type Scanner struct { - CIDR string - Timeout time.Duration + CIDR string + Timeout time.Duration // 单 IP 探测超时 + Concurrency int // 并发数,默认 30 } // NewScanner 创建扫描器 -func NewScanner(cidr string, timeout time.Duration) *Scanner { +func NewScanner(cidr string, timeout time.Duration, concurrency int) *Scanner { + if concurrency <= 0 { + concurrency = 10 + } return &Scanner{ - CIDR: cidr, - Timeout: timeout, + CIDR: cidr, + Timeout: timeout, + Concurrency: concurrency, } } -// Scan 扫描网段,返回存活设备列表(含 MAC 地址) -// 串行逐个探测,每个 IP 有超时保护 +// Scan 并发 ping 扫描网段,返回存活设备列表(仅含 IP,MAC 留空待 SSH 阶段回填) func (s *Scanner) Scan() []model.Device { ips, ok := s.candidateIPs() if !ok { return nil } - fmt.Printf("[扫描] 网段 %s,共 %d 个IP,开始探测...\n", s.CIDR, len(ips)) + fmt.Printf("[扫描] 网段 %s,共 %d 个IP,%d 并发 ping 探测...\n", s.CIDR, len(ips), s.Concurrency) - var results []model.Device - for _, ip := range ips { - if dev, ok := s.probe(ip); ok { - results = append(results, dev) - } + jobs := make(chan string, len(ips)) + results := make(chan model.Device, len(ips)) + + var wg sync.WaitGroup + for i := 0; i < s.Concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for ip := range jobs { + if s.ping(ip) { + results <- model.Device{IP: ip, MAC: ""} + } + } + }() } - fmt.Printf("[扫描] 完成,发现 %d 台存活设备\n", len(results)) - return results + for _, ip := range ips { + jobs <- ip + } + close(jobs) + + wg.Wait() + close(results) + + var devices []model.Device + for dev := range results { + devices = append(devices, dev) + } + + fmt.Printf("[扫描] 完成,发现 %d 台存活设备\n", len(devices)) + return devices } // candidateIPs 解析网段并生成候选 IP 列表(排除网络地址和广播地址) -func (s *Scanner) candidateIPs() ([]net.IP, bool) { - ip, ipnet, err := net.ParseCIDR(s.CIDR) +func (s *Scanner) candidateIPs() ([]string, bool) { + _, ipnet, err := net.ParseCIDR(s.CIDR) if err != nil { fmt.Printf("[扫描] 网段解析失败: %v\n", err) return nil, false } - var ips []net.IP - for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) { - dst := make(net.IP, len(ip)) - copy(dst, ip) - ips = append(ips, dst) + ip := ipnet.IP.Mask(ipnet.Mask) + var ips []string + for ; ipnet.Contains(ip); inc(ip) { + ips = append(ips, ip.String()) } if len(ips) > 2 { ips = ips[1 : len(ips)-1] @@ -63,39 +90,47 @@ func (s *Scanner) candidateIPs() ([]net.IP, bool) { return ips, true } -// probe 探测单个 IP,存活则返回设备信息(MAC 通过 ARP 表查询) -func (s *Scanner) probe(ip net.IP) (model.Device, bool) { - if !s.isAlive(ip) { - return model.Device{}, false +// ping 探测单个 IP 是否存活(ICMP,经系统 ping 命令)。 +// 仅凭 exit code 不可靠(Windows 在某些代答/虚拟网卡场景会返回 0), +// 因此解析输出,要求至少收到 1 个回包。 +func (s *Scanner) ping(ip string) bool { + // ping 命令的等待超时(毫秒):固定 1s 足够局域网探测 + const pingWait = 1000 + // context 超时设为 ping 等待的 2 倍,留足 ping 自然退出时间, + // 避免 context 先于 ping -w 杀进程导致拿不到输出。 + ctx, cancel := context.WithTimeout(context.Background(), 2*pingWait*time.Millisecond) + defer cancel() + cmd := exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", pingWait), ip) + out, err := cmd.Output() + if err != nil { + return false } - mac := lookupMAC(ip.String()) - desc := "无MAC" - if mac != "" { - desc = mac - } - fmt.Printf(" [+] %s 存活 (MAC: %s)\n", ip, desc) - return model.Device{IP: ip.String(), MAC: mac}, true + return parsePingReply(string(out)) } -// isAlive 用 ping 探测单个 IP 是否存活(内置超时保护) -func (s *Scanner) isAlive(ip net.IP) bool { - waitMs := s.Timeout.Milliseconds() - if waitMs < 1000 { - waitMs = 1000 +// parsePingReply 判断 ping 输出是否表示存活 +func parsePingReply(out string) bool { + for _, line := range strings.Split(out, "\n") { + l := strings.ToLower(strings.TrimSpace(line)) + // Windows: "Packets: Sent = 1, Received = 1, Lost = 0" + if strings.Contains(l, "received =") { + i := strings.Index(l, "received =") + n := strings.TrimSpace(l[i+len("received ="):]) + // 取数字前缀 + cnt := 0 + for _, c := range n { + if c < '0' || c > '9' { + break + } + cnt = cnt*10 + int(c-'0') + } + return cnt >= 1 + } } - var args []string - switch runtime.GOOS { - case "windows": - args = []string{"-n", "1", "-w", fmt.Sprintf("%d", waitMs), ip.String()} - case "darwin": - args = []string{"-c", "1", "-W", fmt.Sprintf("%d", waitMs), ip.String()} - default: - args = []string{"-c", "1", "-W", fmt.Sprintf("%d", waitMs/1000), ip.String()} - } - return exec.Command("ping", args...).Run() == nil + return false } -// inc IP 递增 +// inc IP 递增(原地修改,调用方负责复制) func inc(ip net.IP) { for j := len(ip) - 1; j >= 0; j-- { ip[j]++ diff --git a/auto-check/pkg/model/model.go b/auto-check/pkg/model/model.go index 1d41fd9..3759963 100644 --- a/auto-check/pkg/model/model.go +++ b/auto-check/pkg/model/model.go @@ -6,14 +6,18 @@ import "fmt" // 公共类型 — 全包共用,唯一来源 // ============================ -// Device 设备(MAC 为唯一标识) -// 当前仅 IP/MAC 两个字段,后续按需扩展 +// Device 设备 +// 发现阶段以 IP 标识(仅做 ping 可达性探测),MAC 在 SSH 登录后回填, +// 空 = 尚未获取。键(map key)始终为 IP。 type Device struct { IP string // 当前 IP(DHCP 可能变化) - MAC string // 物理地址,唯一标识,空 = 无法获取 + MAC string // 物理地址,SSH 阶段回填,空 = 尚未获取 } // Label 设备显示名(用于日志/报告) func (d Device) Label() string { - return fmt.Sprintf("%s (%s)", d.MAC, d.IP) + if d.MAC != "" { + return fmt.Sprintf("%s (%s)", d.IP, d.MAC) + } + return d.IP } diff --git a/auto-check/pkg/stress/chart.go b/auto-check/pkg/stress/chart.go new file mode 100644 index 0000000..f34becd --- /dev/null +++ b/auto-check/pkg/stress/chart.go @@ -0,0 +1,131 @@ +package stress + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// chartData 给 JS 用的数据 +type chartData struct { + Labels []string `json:"labels"` + CPU []float64 `json:"cpu"` + Temp []float64 `json:"temp"` + Title string `json:"title"` +} + +// WriteChartHTML 根据采样数据生成折线图 HTML 文件,返回文件路径 +func WriteChartHTML(ip string, result Result, reportDir string) (string, error) { + if len(result.Samples) == 0 { + return "", nil + } + + cd := chartData{ + Title: fmt.Sprintf("%s - %s 压测曲线 (%s)", ip, result.Type, result.Duration), + } + for _, s := range result.Samples { + cd.Labels = append(cd.Labels, s.Time) + cd.CPU = append(cd.CPU, s.CPU) + cd.Temp = append(cd.Temp, s.Temp) + } + + dataJSON, err := json.Marshal(cd) + if err != nil { + return "", err + } + + html := strings.Replace(chartHTMLTemplate, "__DATA__", string(dataJSON), 1) + + os.MkdirAll(reportDir, 0755) + filename := fmt.Sprintf("%s/chart-%s-%s.html", reportDir, result.Type, SanitizeFilename(ip)) + if err := os.WriteFile(filename, []byte(html), 0644); err != nil { + return "", err + } + return filename, nil +} + +// SanitizeFilename 清理 IP 中的特殊字符,用于文件名 +func SanitizeFilename(ip string) string { + return strings.ReplaceAll(ip, ":", "_") +} + +const chartHTMLTemplate = ` + + + + +压测曲线 + + + + +
+

+ +
+ CPU 使用率 (%) + 温度 (°C) +
+
+ + +` diff --git a/auto-check/pkg/stress/runner.go b/auto-check/pkg/stress/runner.go index 10ef756..a979c91 100644 --- a/auto-check/pkg/stress/runner.go +++ b/auto-check/pkg/stress/runner.go @@ -2,11 +2,14 @@ package stress import ( "fmt" + "net" + "os" "strconv" "strings" "time" "auto-check/pkg/config" + "auto-check/pkg/discovery" "auto-check/pkg/sshclient" "golang.org/x/crypto/ssh" @@ -56,11 +59,11 @@ func (r *Runner) Run(ip string) *Report { } fmt.Println() - // 生成脚本 - script := BuildScript(r.metrics, r.cfg) + // 按需读取各测试脚本,拼装成完整脚本 + script := r.assembleScript() + fmt.Printf(" [脚本] 拼装完成,%d 字节\n", len(script)) - // 远程执行脚本(通过 stdin 传入) - fmt.Printf(" [执行] 生成脚本 %d 字节,开始远程执行...\n", len(script)) + // 通过 heredoc 传到远程执行 scriptCmd := fmt.Sprintf("cat <<'AUTOCHECKSCRIPT' > /tmp/auto-check.sh\n%s\nAUTOCHECKSCRIPT\nchmod +x /tmp/auto-check.sh && bash /tmp/auto-check.sh 2>&1", script) output, err := sshclient.RunCommand(r.client, scriptCmd) @@ -73,6 +76,18 @@ 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) fmt.Printf("\n════════ [%s] 压力测试完成 ════════\n", ip) @@ -87,6 +102,8 @@ func (r *Runner) parseResults(report *Report, output string) { var currentStatus string var currentDuration string var currentOutput []string + var currentSamples []Sample + inSamples := false flush := func() { if currentTest == "" { @@ -107,16 +124,41 @@ func (r *Runner) parseResults(report *Report, output string) { Status: status, Output: strings.Join(currentOutput, "\n"), Duration: duration, + Samples: currentSamples, }) currentTest = "" currentStatus = "" currentDuration = "" currentOutput = nil + currentSamples = nil } for _, line := range lines { line = strings.TrimSpace(line) + // 采样数据块 + if line == "===SAMPLES===" { + inSamples = true + continue + } + if line == "===END_SAMPLES===" { + inSamples = false + continue + } + 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, + }) + } + continue + } + if strings.HasPrefix(line, "===TEST:") { flush() currentTest = strings.TrimSuffix(strings.TrimPrefix(line, "===TEST:"), "===") @@ -169,6 +211,81 @@ func (r *Runner) parseResults(report *Report, output string) { } } +// assembleScript 根据 metrics 读取对应的独立脚本文件,拼装成完整脚本 +func (r *Runner) assembleScript() string { + var sb strings.Builder + + // shebang + 环境变量导出 + sb.WriteString("#!/bin/bash\n") + sb.WriteString("set -e\n\n") + + // 导出参数变量 + sb.WriteString(fmt.Sprintf("export AUTOCHECK_DURATION=%d\n", int(r.cfg.Duration.Seconds()))) + sb.WriteString(fmt.Sprintf("export AUTOCHECK_THREADS=%d\n", r.cfg.Threads)) + sb.WriteString(fmt.Sprintf("export AUTOCHECK_DISK_SIZE_MB=%d\n", r.cfg.DiskSizeMB)) + sb.WriteString(fmt.Sprintf("export AUTOCHECK_MEM_SIZE_MB=%d\n", r.cfg.MemSizeMB)) + sb.WriteString(fmt.Sprintf("export AUTOCHECK_TEMP_INTERVAL=%d\n", int(r.cfg.TempLogInt.Seconds()))) + sb.WriteString("export AUTOCHECK_SAMPLE_INTERVAL=2\n") + if r.cfg.DiskDir != "" { + sb.WriteString(fmt.Sprintf("export AUTOCHECK_DISK_DIR=%s\n", r.cfg.DiskDir)) + } + sb.WriteString("\n") + + // 系统信息 + sb.Write(r.readScript("sysinfo")) + sb.WriteString("\n") + + // 温度监控启动 + hasTemp := false + for _, m := range r.metrics { + if m.Name == "temp" && m.Enabled { + hasTemp = true + break + } + } + if hasTemp { + sb.Write(r.readScript("temp_start")) + sb.WriteString("\n") + } + + // 逐个测试脚本 + for _, m := range r.metrics { + if m.IsMonitor || !m.Enabled { + continue + } + data := r.readScript(m.Name) + if data == nil { + sb.WriteString(fmt.Sprintf("echo '===TEST:%s==='\n", m.Name)) + sb.WriteString("echo 'status:skip'\n") + sb.WriteString(fmt.Sprintf("echo 'output:脚本文件 scripts/%s.sh 不存在'\n", m.Name)) + sb.WriteString("echo ''\n") + continue + } + sb.Write(data) + sb.WriteString("\n") + } + + // 温度监控收尾 + if hasTemp { + sb.Write(r.readScript("temp_end")) + sb.WriteString("\n") + } + + // dmesg + 结束标记 + sb.Write(r.readScript("dmesg")) + + return sb.String() +} + +// readScript 读取 scripts/ 目录下的脚本文件 +func (r *Runner) readScript(name string) []byte { + data, err := os.ReadFile(fmt.Sprintf("scripts/%s.sh", name)) + if err != nil { + return nil + } + return data +} + // ============================ // 业务入口(workflow 调用) // ============================ @@ -184,6 +301,15 @@ func TestDevice(ip string, cfg config.Config) *Report { } defer conn.Close() + // SSH 登录成功后回填远端 MAC(发现阶段只做了 ping,暂无 MAC) + if mac := queryRemoteMAC(conn); mac != "" { + if dev, ok := discovery.Devices[ip]; ok { + dev.MAC = mac + discovery.Devices[ip] = dev + } + fmt.Printf(" [MAC] %s -> %s\n", ip, mac) + } + stressCfg := Config{ Duration: cfg.Stress.Duration, Threads: cfg.Stress.Threads, @@ -203,6 +329,34 @@ func NewSSHFailReport(ip string, err error) *Report { } } +// queryRemoteMAC 查询远端主机的 MAC 地址(取第一个有效的单播地址)。 +// 失败或无有效地址时返回空字符串,不阻断主流程。 +func queryRemoteMAC(client *ssh.Client) string { + out, err := sshclient.RunCommand(client, "cat /sys/class/net/*/address 2>/dev/null") + if err != nil { + return "" + } + for _, line := range strings.Split(out, "\n") { + mac := strings.TrimSpace(line) + if mac == "" { + continue + } + hw, e := net.ParseMAC(mac) + if e != nil || len(hw) != 6 { + continue + } + // 排除零地址、广播、组播 + if hw[0] == 0 && hw[1] == 0 && hw[2] == 0 && hw[3] == 0 && hw[4] == 0 && hw[5] == 0 { + continue + } + if hw[0]&0x01 != 0 { + continue + } + return hw.String() + } + return "" +} + // ============================ // 工具函数 // ============================ diff --git a/auto-check/pkg/stress/script.go b/auto-check/pkg/stress/script.go deleted file mode 100644 index 2829dae..0000000 --- a/auto-check/pkg/stress/script.go +++ /dev/null @@ -1,156 +0,0 @@ -package stress - -import ( - "fmt" - "strings" -) - -// ============================ -// 脚本生成(根据指标生成远程执行的 shell 脚本) -// ============================ - -// BuildScript 根据指标列表和配置生成 self-contained shell 脚本 -// 脚本输出结构化文本,便于 ParseResults 解析 -func BuildScript(metrics []Metric, cfg Config) string { - var sb strings.Builder - - // 脚本头部 - sb.WriteString("#!/bin/bash\n") - sb.WriteString("set -e\n\n") - - // 系统信息探测 - sb.WriteString("echo '===SYSTEM_INFO==='\n") - sb.WriteString("echo hostname:$(hostname)\n") - sb.WriteString("echo cpu:$(lscpu 2>/dev/null | grep 'Model name' | sed 's/Model name:\\s*//' || echo unknown)\n") - sb.WriteString("echo cores:$(nproc 2>/dev/null || echo 0)\n") - sb.WriteString("echo memory:$(free -h 2>/dev/null | awk '/Mem:/{print $2}' || echo unknown)\n") - sb.WriteString("echo kernel:$(uname -r 2>/dev/null || echo unknown)\n") - sb.WriteString("echo disk:$(lsblk -d -o NAME,SIZE 2>/dev/null | head -3 | tr '\\n' ' ')\n") - sb.WriteString("echo ''\n\n") - - // 启动温度监控(后台) - hasTemp := false - for _, m := range metrics { - if m.Name == "temp" && m.Enabled { - hasTemp = true - break - } - } - if hasTemp { - tempInterval := int(cfg.TempLogInt.Seconds()) - if tempInterval <= 0 { - tempInterval = 10 - } - sb.WriteString(fmt.Sprintf("# 温度监控(后台)\n")) - sb.WriteString("TEMP_LOG=/tmp/auto-check-temp.log\n") - sb.WriteString("> $TEMP_LOG\n") - sb.WriteString(fmt.Sprintf("(while true; do sensors 2>/dev/null | grep -i 'temp\\|core\\|cpu' | head -5 >> $TEMP_LOG; sleep %d; done) &\n", tempInterval)) - sb.WriteString("TEMP_PID=$!\n\n") - } - - // 逐项执行测试 - for _, m := range metrics { - if m.IsMonitor || !m.Enabled { - continue - } - sb.WriteString(fmt.Sprintf("echo '===TEST:%s==='\n", m.Name)) - sb.WriteString("START_TIME=$(date +%%s%%N)\n") - sb.WriteString("set +e\n") - - cmd := buildTestCommand(m, cfg) - sb.WriteString(fmt.Sprintf("OUTPUT=$(%s 2>&1)\n", cmd)) - sb.WriteString("EXIT_CODE=$?\n") - sb.WriteString("set -e\n") - sb.WriteString("END_TIME=$(date +%%s%%N)\n") - sb.WriteString("DURATION=$(( (END_TIME - START_TIME) / 1000000 ))\n") - - // 判定结果 - sb.WriteString("if [ $EXIT_CODE -eq 0 ]; then\n") - sb.WriteString(" echo 'status:pass'\n") - sb.WriteString("else\n") - sb.WriteString(" echo 'status:fail'\n") - sb.WriteString("fi\n") - sb.WriteString("echo \"duration:${DURATION}ms\"\n") - sb.WriteString("echo \"output:${OUTPUT}\"\n") - sb.WriteString("echo ''\n\n") - } - - // 收集温度监控结果 - if hasTemp { - sb.WriteString("echo '===MONITOR:temp==='\n") - sb.WriteString("if [ -f $TEMP_LOG ] && [ -s $TEMP_LOG ]; then\n") - sb.WriteString(" echo 'status:pass'\n") - sb.WriteString(" TEMP_MAX=$(grep -oP '\\d+\\.?\\d*°C' $TEMP_LOG | sort -t. -k1 -n | tail -1 || echo 'unknown')\n") - sb.WriteString(" echo \"samples:$(wc -l < $TEMP_LOG)\"\n") - sb.WriteString(" echo \"max:$TEMP_MAX\"\n") - sb.WriteString(" echo \"output:$(tail -10 $TEMP_LOG)\"\n") - sb.WriteString("else\n") - sb.WriteString(" echo 'status:skip'\n") - sb.WriteString("fi\n") - sb.WriteString("echo ''\n") - sb.WriteString("kill $TEMP_PID 2>/dev/null || true\n\n") - } - - // 收集 dmesg 结果 - sb.WriteString("echo '===MONITOR:dmesg==='\n") - sb.WriteString("DMESG_ERR=$(dmesg --level=err,crit,alert,emerg 2>/dev/null | tail -30 || true)\n") - sb.WriteString("if [ -n \"$DMESG_ERR\" ]; then\n") - sb.WriteString(" echo 'status:fail'\n") - sb.WriteString(" echo \"output:$DMESG_ERR\"\n") - sb.WriteString("else\n") - sb.WriteString(" echo 'status:pass'\n") - sb.WriteString(" echo 'output:无硬件相关内核报错'\n") - sb.WriteString("fi\n") - sb.WriteString("echo ''\n") - sb.WriteString("echo '===END==='\n") - - return sb.String() -} - -// buildTestCommand 根据指标生成测试命令 -func buildTestCommand(m Metric, cfg Config) string { - sec := int(cfg.Duration.Seconds()) - threads := cfg.Threads - - switch m.Name { - case "cpu": - return fmt.Sprintf("stress-ng --cpu %d --cpu-method all --timeout %ds --metrics-brief --temp-path /tmp", threads, sec) - - case "memory": - workers := threads - if workers > 4 { - workers = 4 - } - return fmt.Sprintf("stress-ng --vm %d --vm-bytes 256M --vm-method all --timeout %ds --metrics-brief", workers, sec) - - case "disk": - testDir := cfg.DiskDir - if testDir == "" { - testDir = "/tmp/stress-disk-test" - } - diskMB := cfg.DiskSizeMB - if diskMB <= 0 { - diskMB = 1024 - } - return fmt.Sprintf("mkdir -p %s && stress-ng --iomix 2 --iomix-bytes %dM --timeout %ds --metrics-brief && rm -rf %s", - testDir, diskMB, sec, testDir) - - case "memnative": - memMB := cfg.MemSizeMB - if memMB <= 0 { - memMB = 0 // 脚本内自动计算 - } - if memMB > 0 { - return fmt.Sprintf("stressapptest -s %d -M %d -f 0 -v", sec, memMB) - } - // 自动计算可用内存的 60% - return fmt.Sprintf("M=$(free -m 2>/dev/null | awk '/Mem:/{print int($7*0.6)}' || echo 512) && stressapptest -s %d -M $M -f 0 -v", sec) - - case "full": - return fmt.Sprintf("stress-ng --cpu %d --vm 2 --vm-bytes 128M --iomix 1 --iomix-bytes 256M --timeout %ds --metrics-brief", - threads, sec) - - default: - return "echo '未知测试类型'" - } -} diff --git a/auto-check/pkg/stress/types.go b/auto-check/pkg/stress/types.go index 1ca82d6..79ffc06 100644 --- a/auto-check/pkg/stress/types.go +++ b/auto-check/pkg/stress/types.go @@ -35,6 +35,14 @@ type Result struct { Output string Error string Duration time.Duration + Samples []Sample // 时序采样数据(cpu 等测试采集) +} + +// Sample 单次采样点 +type Sample struct { + Time string // HH:MM:SS + CPU float64 // CPU 使用率 % + Temp float64 // 温度 °C } // Report 完整压测报告 diff --git a/auto-check/pkg/workflow/workflow.go b/auto-check/pkg/workflow/workflow.go index d56f811..af63604 100644 --- a/auto-check/pkg/workflow/workflow.go +++ b/auto-check/pkg/workflow/workflow.go @@ -60,7 +60,7 @@ func (w *Workflow) round() { // 1. 扫描(discovery 包),更新设备表 cfg := w.cfg - discovery.NewScanner(cfg.Scan.CIDR, cfg.Scan.Timeout).Discover() + discovery.NewScanner(cfg.Scan.CIDR, cfg.Scan.Timeout, cfg.Scan.Concurrency).Discover() fmt.Printf(" [扫描] 设备 %d 台\n", len(discovery.Devices)) // 2. 压测未通过设备(stress.TestDevice 执行,report 保存+打印) diff --git a/auto-check/scripts/cpu.sh b/auto-check/scripts/cpu.sh new file mode 100644 index 0000000..00acf23 --- /dev/null +++ b/auto-check/scripts/cpu.sh @@ -0,0 +1,37 @@ +echo "===TEST:cpu===" + +# 后台采样 CPU 使用率 + 温度 +SAMPLE_LOG=/tmp/auto-check-cpu-sample.log +> "$SAMPLE_LOG" +( + while true; do + TS=$(date +%H:%M:%S) + CPU=$(top -bn1 2>/dev/null | grep "Cpu(s)" | sed 's/.*,\s*\([0-9.]*\)%*\s*id.*/\1/' | awk '{printf "%.1f", 100-$1}' || echo 0) + TEMP=$(sensors 2>/dev/null | grep -iE 'Package|Core\s*0|temp1|CPU' | head -1 | grep -oP '\d+\.?\d*' | head -1 || echo 0) + echo "${TS},${CPU},${TEMP}" >> "$SAMPLE_LOG" + sleep "${AUTOCHECK_SAMPLE_INTERVAL:-2}" + done +) & +SAMPLE_PID=$! + +START_TIME=$(date +%s%N) +set +e +OUTPUT=$(stress-ng --cpu "${AUTOCHECK_THREADS:-4}" --cpu-method all --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief --temp-path /tmp 2>&1) +EXIT_CODE=$? +set -e +END_TIME=$(date +%s%N) + +kill $SAMPLE_PID 2>/dev/null || true +wait $SAMPLE_PID 2>/dev/null || true + +DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) +if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi +echo "duration:${DURATION_MS}ms" +echo "output:${OUTPUT}" + +# 输出采样数据 +echo "===SAMPLES===" +cat "$SAMPLE_LOG" +echo "===END_SAMPLES===" + +echo '' diff --git a/auto-check/scripts/disk.sh b/auto-check/scripts/disk.sh new file mode 100644 index 0000000..472ee6b --- /dev/null +++ b/auto-check/scripts/disk.sh @@ -0,0 +1,14 @@ +echo "===TEST:disk===" +TEST_DIR=${AUTOCHECK_DISK_DIR:-/tmp/stress-disk-test} +DISK_SIZE_MB=${AUTOCHECK_DISK_SIZE_MB:-1024} +START_TIME=$(date +%s%N) +set +e +OUTPUT=$(mkdir -p "${TEST_DIR}" && stress-ng --iomix 2 --iomix-bytes "${DISK_SIZE_MB}M" --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1; rm -rf "${TEST_DIR}") +EXIT_CODE=$? +set -e +END_TIME=$(date +%s%N) +DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) +if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi +echo "duration:${DURATION_MS}ms" +echo "output:${OUTPUT}" +echo '' diff --git a/auto-check/scripts/dmesg.sh b/auto-check/scripts/dmesg.sh new file mode 100644 index 0000000..fa0fbfd --- /dev/null +++ b/auto-check/scripts/dmesg.sh @@ -0,0 +1,11 @@ +echo '===MONITOR:dmesg===' +DMESG_ERR=$(dmesg --level=err,crit,alert,emerg 2>/dev/null | tail -30 || true) +if [ -n "$DMESG_ERR" ]; then + echo 'status:fail' + echo "output:${DMESG_ERR}" +else + echo 'status:pass' + echo 'output:无硬件相关内核报错' +fi +echo '' +echo '===END===' diff --git a/auto-check/scripts/full.sh b/auto-check/scripts/full.sh new file mode 100644 index 0000000..d33a950 --- /dev/null +++ b/auto-check/scripts/full.sh @@ -0,0 +1,12 @@ +echo "===TEST:full===" +START_TIME=$(date +%s%N) +set +e +OUTPUT=$(stress-ng --cpu "${AUTOCHECK_THREADS:-4}" --vm 2 --vm-bytes 128M --iomix 1 --iomix-bytes 256M --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1) +EXIT_CODE=$? +set -e +END_TIME=$(date +%s%N) +DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) +if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi +echo "duration:${DURATION_MS}ms" +echo "output:${OUTPUT}" +echo '' diff --git a/auto-check/scripts/memnative.sh b/auto-check/scripts/memnative.sh new file mode 100644 index 0000000..a01af17 --- /dev/null +++ b/auto-check/scripts/memnative.sh @@ -0,0 +1,19 @@ +echo "===TEST:memnative===" +MEM_SIZE_MB=${AUTOCHECK_MEM_SIZE_MB:-0} +if [ "$MEM_SIZE_MB" -gt 0 ]; then + MEM_ARG="-M ${MEM_SIZE_MB}" +else + M=$(free -m 2>/dev/null | awk '/Mem:/{print int($7*0.6)}' || echo 512) + MEM_ARG="-M ${M}" +fi +START_TIME=$(date +%s%N) +set +e +OUTPUT=$(stressapptest -s "${AUTOCHECK_DURATION:-30}" ${MEM_ARG} -f 0 -v 2>&1) +EXIT_CODE=$? +set -e +END_TIME=$(date +%s%N) +DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) +if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi +echo "duration:${DURATION_MS}ms" +echo "output:${OUTPUT}" +echo '' diff --git a/auto-check/scripts/memory.sh b/auto-check/scripts/memory.sh new file mode 100644 index 0000000..bd3db6a --- /dev/null +++ b/auto-check/scripts/memory.sh @@ -0,0 +1,14 @@ +echo "===TEST:memory===" +VM_WORKERS=${AUTOCHECK_THREADS:-4} +if [ "$VM_WORKERS" -gt 4 ]; then VM_WORKERS=4; fi +START_TIME=$(date +%s%N) +set +e +OUTPUT=$(stress-ng --vm "${VM_WORKERS}" --vm-bytes 256M --vm-method all --timeout "${AUTOCHECK_DURATION:-30}s" --metrics-brief 2>&1) +EXIT_CODE=$? +set -e +END_TIME=$(date +%s%N) +DURATION_MS=$(( (END_TIME - START_TIME) / 1000000 )) +if [ "$EXIT_CODE" -eq 0 ]; then echo 'status:pass'; else echo 'status:fail'; fi +echo "duration:${DURATION_MS}ms" +echo "output:${OUTPUT}" +echo '' diff --git a/auto-check/scripts/sysinfo.sh b/auto-check/scripts/sysinfo.sh new file mode 100644 index 0000000..a2e234b --- /dev/null +++ b/auto-check/scripts/sysinfo.sh @@ -0,0 +1,8 @@ +echo '===SYSTEM_INFO===' +echo "hostname:$(hostname)" +echo "cpu:$(lscpu 2>/dev/null | grep 'Model name' | sed 's/Model name:\s*//' || echo unknown)" +echo "cores:$(nproc 2>/dev/null || echo 0)" +echo "memory:$(free -h 2>/dev/null | awk '/Mem:/{print $2}' || echo unknown)" +echo "kernel:$(uname -r 2>/dev/null || echo unknown)" +echo "disk:$(lsblk -d -o NAME,SIZE 2>/dev/null | head -3 | tr '\n' ' ')" +echo '' diff --git a/auto-check/scripts/temp_end.sh b/auto-check/scripts/temp_end.sh new file mode 100644 index 0000000..7b216ef --- /dev/null +++ b/auto-check/scripts/temp_end.sh @@ -0,0 +1,12 @@ +echo '===MONITOR:temp===' +if [ -f "$TEMP_LOG" ] && [ -s "$TEMP_LOG" ]; then + echo 'status:pass' + TEMP_MAX=$(grep -oP '\d+\.?\d*°C' "$TEMP_LOG" | sort -t. -k1 -n | tail -1 || echo 'unknown') + echo "samples:$(wc -l < "$TEMP_LOG")" + echo "max:${TEMP_MAX}" + echo "output:$(tail -10 "$TEMP_LOG")" +else + echo 'status:skip' +fi +echo '' +kill $TEMP_PID 2>/dev/null || true diff --git a/auto-check/scripts/temp_start.sh b/auto-check/scripts/temp_start.sh new file mode 100644 index 0000000..1dba5e7 --- /dev/null +++ b/auto-check/scripts/temp_start.sh @@ -0,0 +1,4 @@ +TEMP_LOG=/tmp/auto-check-temp.log +> "$TEMP_LOG" +(while true; do sensors 2>/dev/null | grep -i 'temp\|core\|cpu' | head -5 >> "$TEMP_LOG"; sleep "${AUTOCHECK_TEMP_INTERVAL:-10}"; done) & +TEMP_PID=$!