diff --git a/auto-check/.codewhale/state/subagents.v1.lock b/auto-check/.codewhale/state/subagents.v1.lock new file mode 100644 index 0000000..e69de29 diff --git a/auto-check/auto-check.yaml b/auto-check/auto-check.yaml index c079b44..8294f88 100644 --- a/auto-check/auto-check.yaml +++ b/auto-check/auto-check.yaml @@ -24,6 +24,13 @@ stress: workflow: interval: 10s -# --- 报告 --- +# --- 报告与打印 --- report: path: "reports" + print: + enabled: false # 是否自动打印报告 + backend: "usb" # system=系统打印机 / escpos=网络热敏 / usb=USB直连 / file=文件 + device: "" # system: 打印机名(空=默认), escpos: 192.168.1.100:9100 + # usb: 空=自动(Win \\.\usb001 / Linux /dev/usb/lp0), 或指定设备路径 + # file: 输出路径 + width: 42 # 80mm 热敏纸行宽 diff --git a/auto-check/cmd/run.go b/auto-check/cmd/run.go index 969ef69..2196eeb 100644 --- a/auto-check/cmd/run.go +++ b/auto-check/cmd/run.go @@ -22,9 +22,7 @@ var runCmd = &cobra.Command{ cfg.Scan.CIDR, cfg.Workflow.Interval, cfg.Stress.Types) // 启动工作流(永久循环) - ctx := workflow.NewContext() - ctx.Config = *cfg - return workflow.Loop(ctx) + return workflow.New(*cfg).Start() }, } diff --git a/auto-check/go.mod b/auto-check/go.mod index c1e0deb..5e42464 100644 --- a/auto-check/go.mod +++ b/auto-check/go.mod @@ -1,6 +1,6 @@ module auto-check -go 1.25.1 +go 1.25.0 require ( github.com/spf13/cobra v1.10.2 diff --git a/auto-check/pkg/config/config.go b/auto-check/pkg/config/config.go index c16a2f5..6f971ef 100644 --- a/auto-check/pkg/config/config.go +++ b/auto-check/pkg/config/config.go @@ -49,7 +49,17 @@ type StressConfig struct { // ReportConfig 报告 type ReportConfig struct { - Path string `yaml:"path"` + Path string `yaml:"path"` // 报告保存路径 + Print PrintConfig `yaml:"print"` // 打印配置 +} + +// PrintConfig 打印配置 +type PrintConfig struct { + Enabled bool `yaml:"enabled"` // 是否打印 + Backend string `yaml:"backend"` // system / escpos / usb / file + Device string `yaml:"device"` // 目标:打印机名 / host:port / 设备路径 / 文件路径 + Width int `yaml:"width"` // 行宽,80mm=42 + Charset string `yaml:"charset"` // 字符集(默认 utf-8) } // WorkflowConfig 工作流 diff --git a/auto-check/pkg/discovery/discovery.go b/auto-check/pkg/discovery/discovery.go index 4b2be06..aa59577 100644 --- a/auto-check/pkg/discovery/discovery.go +++ b/auto-check/pkg/discovery/discovery.go @@ -9,40 +9,51 @@ import ( "strings" "sync" "time" + + "auto-check/pkg/model" ) -// Host 存活主机信息 -type Host struct { - IP net.IP - MAC string // 物理地址(设备唯一标识) - Alive bool - OpenPort int -} +// Devices 设备表(IP → 设备),每轮 Discover() 后更新,包级公开 +var Devices = make(map[string]model.Device) // Scanner 网段扫描器 type Scanner struct { CIDR string - Port int Timeout time.Duration Concurrency int } // NewScanner 创建扫描器 -func NewScanner(cidr string, port int, timeout time.Duration, concurrency int) *Scanner { +func NewScanner(cidr string, timeout time.Duration, concurrency int) *Scanner { return &Scanner{ CIDR: cidr, - Port: port, Timeout: timeout, Concurrency: concurrency, } } -// Scan 扫描网段,返回存活主机列表 -func (s *Scanner) Scan() []Host { +// Scan 扫描网段,返回存活设备列表 +func (s *Scanner) Scan() []model.Device { + ips, ok := s.candidateIPs() + if !ok { + return nil + } + + fmt.Printf("[扫描] 网段 %s,共 %d 个IP,开始探测...\n", s.CIDR, len(ips)) + + results := s.probeAll(ips) + + fmt.Printf("[扫描] 完成,发现 %d 台存活设备\n", len(results)) + return results +} + +// candidateIPs 解析网段并生成候选 IP 列表(排除网络地址和广播地址) +// 网段解析失败时返回 false +func (s *Scanner) candidateIPs() ([]net.IP, bool) { ip, ipnet, err := net.ParseCIDR(s.CIDR) if err != nil { fmt.Printf("[扫描] 网段解析失败: %v\n", err) - return nil + return nil, false } var ips []net.IP @@ -55,13 +66,14 @@ func (s *Scanner) Scan() []Host { if len(ips) > 2 { ips = ips[1 : len(ips)-1] } + return ips, true +} - fmt.Printf("[扫描] 网段 %s,共 %d 个IP,开始探测...\n", s.CIDR, len(ips)) - - // 并发探测 +// probeAll 并发探测所有候选 IP,返回存活设备列表 +func (s *Scanner) probeAll(ips []net.IP) []model.Device { var mu sync.Mutex var wg sync.WaitGroup - var results []Host + var results []model.Device sem := make(chan struct{}, s.Concurrency) for _, ip := range ips { @@ -71,54 +83,54 @@ func (s *Scanner) Scan() []Host { defer wg.Done() defer func() { <-sem }() - h := s.probe(target) - if h.Alive { - // 获取 MAC 地址(TCP 探测已触发 ARP 解析) - h.MAC = getMAC(target.String()) - mu.Lock() - results = append(results, h) - mu.Unlock() - desc := "无MAC" - if h.MAC != "" { - desc = h.MAC - } - fmt.Printf(" [+] %s 存活 (MAC: %s, 端口 %d 开放)\n", target, desc, h.OpenPort) + dev, ok := s.probeDevice(target) + if !ok { + return } + mu.Lock() + results = append(results, dev) + mu.Unlock() + desc := "无MAC" + if dev.MAC != "" { + desc = dev.MAC + } + fmt.Printf(" [+] %s 存活 (MAC: %s)\n", target, desc) }(ip) } wg.Wait() - - fmt.Printf("[扫描] 完成,发现 %d 台存活主机\n", len(results)) return results } -// probe 探测单个 IP -func (s *Scanner) probe(ip net.IP) Host { - h := Host{IP: ip} - - // TCP 端口探测(连接成功会触发本地 ARP 解析) - addr := net.JoinHostPort(ip.String(), fmt.Sprintf("%d", s.Port)) - conn, err := net.DialTimeout("tcp", addr, s.Timeout) - if err == nil { - conn.Close() - h.Alive = true - h.OpenPort = s.Port - return h +// probeDevice 探测单个 IP,存活则返回设备信息 +func (s *Scanner) probeDevice(ip net.IP) (model.Device, bool) { + if !s.probe(ip) { + return model.Device{}, false } - - // 备选:尝试常见端口 - for _, p := range []int{22, 80, 443, 8080, 3306} { - addr = net.JoinHostPort(ip.String(), fmt.Sprintf("%d", p)) - conn, err = net.DialTimeout("tcp", addr, s.Timeout) - if err == nil { - conn.Close() - h.Alive = true - h.OpenPort = p - return h - } + // 获取 MAC 地址(ping 探测已触发 ARP 解析) + dev := model.Device{ + IP: ip.String(), + MAC: getMAC(ip.String()), } + return dev, true +} - return h +// probe 用 ping 探测单个 IP 是否存活,返回 bool +func (s *Scanner) probe(ip net.IP) bool { + waitMs := s.Timeout.Milliseconds() + if waitMs < 1000 { + waitMs = 1000 // ping 等待至少 1 秒 + } + // 各平台 ping 参数不同:Linux -W 秒,macOS/Windows -W/-w 毫秒 + 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 } // getMAC 从 ARP 表获取 IP 对应的 MAC 地址(跨平台) @@ -194,6 +206,19 @@ func macFromUnixArp(ip string) string { return "" } +// Discover 扫描并更新包级 Devices map(过滤无 MAC 设备) +func (s *Scanner) Discover() { + devices := s.Scan() + Devices = make(map[string]model.Device, len(devices)) + for _, dev := range devices { + if dev.MAC == "" { + fmt.Printf(" [!] %s 无 MAC(ARP 未解析),下轮重试\n", dev.IP) + continue + } + Devices[dev.IP] = dev + } +} + // inc IP 递增 func inc(ip net.IP) { for j := len(ip) - 1; j >= 0; j-- { diff --git a/auto-check/pkg/model/model.go b/auto-check/pkg/model/model.go new file mode 100644 index 0000000..1d41fd9 --- /dev/null +++ b/auto-check/pkg/model/model.go @@ -0,0 +1,19 @@ +package model + +import "fmt" + +// ============================ +// 公共类型 — 全包共用,唯一来源 +// ============================ + +// Device 设备(MAC 为唯一标识) +// 当前仅 IP/MAC 两个字段,后续按需扩展 +type Device struct { + IP string // 当前 IP(DHCP 可能变化) + MAC string // 物理地址,唯一标识,空 = 无法获取 +} + +// Label 设备显示名(用于日志/报告) +func (d Device) Label() string { + return fmt.Sprintf("%s (%s)", d.MAC, d.IP) +} diff --git a/auto-check/pkg/report/device.go b/auto-check/pkg/report/device.go new file mode 100644 index 0000000..f128210 --- /dev/null +++ b/auto-check/pkg/report/device.go @@ -0,0 +1,77 @@ +package report + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "auto-check/pkg/config" + "auto-check/pkg/model" + "auto-check/pkg/stress" +) + +// BuildDeviceReport 由压测结果构建设备报告(MAC 唯一标识) +func BuildDeviceReport(dev *model.Device, rpt *stress.Report) *Report { + b := NewBuilder(ConfigSummary{}) + b.StartPhase("压力测试") + b.AddHost(HostResult{MAC: dev.MAC, IP: dev.IP}) + for _, res := range rpt.Results { + b.AddStressResult(dev.MAC, StressResult{ + Type: string(res.Type), + Status: res.Status, + Duration: res.Duration.String(), + Error: res.Error, + Output: res.Output, + }) + } + b.EndPhase(rpt.Summary()) + return b.Build() +} + +// SaveDeviceReport 保存设备报告(文件名含 MAC/IP/时间戳) +func SaveDeviceReport(dev *model.Device, rpt *stress.Report, dir string) error { + if dir == "" { + dir = "reports" + } + ts := time.Now().Format("20060102-150405") + name := strings.ReplaceAll(dev.MAC, ":", "") + path := filepath.Join(dir, fmt.Sprintf("report-%s-%s-%s.txt", name, dev.IP, ts)) + return BuildDeviceReport(dev, rpt).SaveFile(path, "text") +} + +// PrintDeviceReport 渲染 80mm 模板并送打印机 +func PrintDeviceReport(dev *model.Device, rpt *stress.Report, cfg config.PrintConfig) error { + width := cfg.Width + if width <= 0 { + width = 42 + } + + text, err := BuildDeviceReport(dev, rpt).Render80mmWithConfig(TemplateConfig{Width: width}) + if err != nil { + return err + } + + printer, err := NewPrinter(config.PrintConfig{Enabled: true, Backend: cfg.Backend, Device: cfg.Device, Width: width}) + if err != nil { + return err + } + defer printer.Close() + + return printer.Print(text) +} + +// SaveAndPrintDeviceReport 保存报告并(启用时)打印,含错误输出(一站式业务入口) +func SaveAndPrintDeviceReport(dev *model.Device, rpt *stress.Report, dir string, printCfg config.PrintConfig) { + if err := SaveDeviceReport(dev, rpt, dir); err != nil { + fmt.Printf(" [报告] %s 保存失败: %v\n", dev.IP, err) + } + + if printCfg.Enabled { + if err := PrintDeviceReport(dev, rpt, printCfg); err != nil { + fmt.Printf(" [打印] %s 失败: %v\n", dev.IP, err) + } else { + fmt.Printf(" [打印] %s 已送出 (%s)\n", dev.IP, printCfg.Backend) + } + } +} diff --git a/auto-check/pkg/report/print.go b/auto-check/pkg/report/print.go new file mode 100644 index 0000000..28b8009 --- /dev/null +++ b/auto-check/pkg/report/print.go @@ -0,0 +1,257 @@ +package report + +import ( + "bufio" + "fmt" + "io" + "net" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "auto-check/pkg/config" +) + +// Printed 打印状态表(IP → 上次打印的状态),包级公开 +// 用于"状态变化才重新打印"策略 +var Printed = make(map[string]string) + +// ============================ +// Printer 接口 — 打印后端抽象 +// ============================ + +// Printer 报告打印机 +type Printer interface { + Print(text string) error + Close() error +} + +// ============================ +// 打印配置 +// ============================ + +// DefaultPrintConfig 默认打印配置(80mm 热敏) +func DefaultPrintConfig() config.PrintConfig { + return config.PrintConfig{ + Backend: "system", + Width: 42, + Charset: "utf-8", + } +} + +// ============================ +// ESC/POS 指令生成(USB 与网络后端共用) +// ============================ + +// writeEscpos 将文本转为 ESC/POS 指令流写入 writer +func writeEscpos(w io.Writer, text string) error { + bw := bufio.NewWriter(w) + + // 初始化打印机 + bw.WriteString("\x1b@") // ESC @ 复位 + bw.WriteString("\x1b3\x18") // 行距 24点 + bw.WriteString("\x1bM\x00") // 标准字体 + bw.WriteString("\x1ba\x00") // 左对齐 + + // 文本内容(UTF-8 直发,打印机需支持) + for _, line := range strings.Split(text, "\n") { + bw.WriteString(line) + bw.WriteString("\n") + } + + // 走纸 + 切纸 + bw.WriteString("\n\n") // 尾部空行 + bw.WriteString("\x1di") // 切纸(部分机型) + return bw.Flush() +} + +// ============================ +// 后端实现 +// ============================ + +// SystemPrinter 系统打印后端(lp/lpr/print) +type SystemPrinter struct { + printerName string + command string +} + +// NewSystemPrinter 创建系统打印机 +// printerName 为空使用系统默认打印机 +func NewSystemPrinter(printerName string) *SystemPrinter { + command := "lpr" + if runtime.GOOS == "windows" { + command = "print" + } + return &SystemPrinter{printerName: printerName, command: command} +} + +// Print 发送文本到系统打印机 +func (p *SystemPrinter) Print(text string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + // 临时文件方式(print 命令需要文件) + tmp, err := os.CreateTemp("", "auto-check-*.txt") + if err != nil { + return err + } + name := tmp.Name() + if _, err := tmp.WriteString(text); err != nil { + tmp.Close() + return err + } + tmp.Close() + defer os.Remove(name) + + if p.printerName != "" { + cmd = exec.Command("cmd", "/c", "print", "/D:"+p.printerName, name) + } else { + cmd = exec.Command("cmd", "/c", "print", name) + } + default: + args := []string{} + if p.printerName != "" { + args = append(args, "-P", p.printerName) + } + cmd = exec.Command(p.command, append(args, "-")...) + cmd.Stdin = strings.NewReader(text) + } + return cmd.Run() +} + +// Close 系统打印无需关闭 +func (p *SystemPrinter) Close() error { return nil } + +// ============================ +// ESCPOSPrinter — 网络热敏直连(9100) +// ============================ + +// ESCPOSPrinter 网络 ESC/POS 打印机 +type ESCPOSPrinter struct { + conn net.Conn +} + +// NewESCPOSPrinter 连接网络 ESC/POS 打印机 +// address 格式: 192.168.1.100:9100 +func NewESCPOSPrinter(address string) (*ESCPOSPrinter, error) { + conn, err := net.DialTimeout("tcp", address, 5*time.Second) + if err != nil { + return nil, fmt.Errorf("连接打印机 %s 失败: %w", address, err) + } + return &ESCPOSPrinter{conn: conn}, nil +} + +// Print 发送文本(自动转 ESC/POS 指令) +func (p *ESCPOSPrinter) Print(text string) error { + return writeEscpos(p.conn, text) +} + +// Close 关闭连接 +func (p *ESCPOSPrinter) Close() error { + if p.conn != nil { + return p.conn.Close() + } + return nil +} + +// ============================ +// USBPrinter — USB 热敏直连 +// ============================ + +// USBPrinter USB ESC/POS 打印机 +// Windows: \\.\usb001(或打印机端口名) +// Linux: /dev/usb/lp0(或 lp1...) +type USBPrinter struct { + dev *os.File +} + +// defaultUSBDevice 默认 USB 设备路径 +func defaultUSBDevice() string { + if runtime.GOOS == "windows" { + return `\\.\usb001` + } + return "/dev/usb/lp0" +} + +// NewUSBPrinter 打开 USB 打印机设备 +// device 为空时自动探测(Linux /dev/usb/lp0,Windows \\.\usb001) +func NewUSBPrinter(device string) (*USBPrinter, error) { + if device == "" || device == "auto" { + device = defaultUSBDevice() + } + + f, err := os.OpenFile(device, os.O_WRONLY, 0) + if err != nil { + return nil, fmt.Errorf("打开 USB 打印机 %s 失败: %w", device, err) + } + return &USBPrinter{dev: f}, nil +} + +// Print 发送文本(自动转 ESC/POS 指令) +func (p *USBPrinter) Print(text string) error { + return writeEscpos(p.dev, text) +} + +// Close 关闭设备 +func (p *USBPrinter) Close() error { + if p.dev != nil { + return p.dev.Close() + } + return nil +} + +// ============================ +// FilePrinter — 文件后端(调试/重定向) +// ============================ + +// FilePrinter 文件打印后端 +type FilePrinter struct { + path string + f *os.File +} + +// NewFilePrinter 创建文件打印机 +func NewFilePrinter(path string) (*FilePrinter, error) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + return &FilePrinter{path: path, f: f}, nil +} + +// Print 追加写入文件 +func (p *FilePrinter) Print(text string) error { + _, err := p.f.WriteString(text + "\n\n") + return err +} + +// Close 关闭文件 +func (p *FilePrinter) Close() error { + return p.f.Close() +} + +// ============================ +// 工厂 +// ============================ + +// NewPrinter 按配置创建打印机 +func NewPrinter(cfg config.PrintConfig) (Printer, error) { + if !cfg.Enabled { + return nil, nil + } + + switch cfg.Backend { + case "system": + return NewSystemPrinter(cfg.Device), nil + case "escpos": + return NewESCPOSPrinter(cfg.Device) + case "usb": + return NewUSBPrinter(cfg.Device) + case "file": + return NewFilePrinter(cfg.Device) + default: + return nil, fmt.Errorf("未知打印后端: %s", cfg.Backend) + } +} diff --git a/auto-check/pkg/report/report.go b/auto-check/pkg/report/report.go index cddbaa6..27187fa 100644 --- a/auto-check/pkg/report/report.go +++ b/auto-check/pkg/report/report.go @@ -11,20 +11,22 @@ import ( // PhaseResult 单阶段结果 type PhaseResult struct { - Phase string `json:"phase" yaml:"phase"` - Status string `json:"status" yaml:"status"` // success / partial / failed + Phase string `json:"phase" yaml:"phase"` + Status string `json:"status" yaml:"status"` // success / partial / failed Hosts []HostResult `json:"hosts" yaml:"hosts"` - Summary string `json:"summary" yaml:"summary"` + Summary string `json:"summary" yaml:"summary"` } -// HostResult 单台主机结果 +// HostResult 单台设备结果(MAC 为唯一标识) type HostResult struct { - IP string `json:"ip" yaml:"ip"` - Alive bool `json:"alive" yaml:"alive"` - OpenPort int `json:"open_port,omitempty" yaml:"open_port,omitempty"` - SSHLogin bool `json:"ssh_login" yaml:"ssh_login"` - SSHErr string `json:"ssh_error,omitempty" yaml:"ssh_error,omitempty"` - Stress []StressResult `json:"stress,omitempty" yaml:"stress,omitempty"` + MAC string `json:"mac" yaml:"mac"` + IP string `json:"ip" yaml:"ip"` + Alive bool `json:"alive" yaml:"alive"` + OpenPort int `json:"open_port,omitempty" yaml:"open_port,omitempty"` + SSHLogin bool `json:"ssh_login" yaml:"ssh_login"` + SSHErr string `json:"ssh_error,omitempty" yaml:"ssh_error,omitempty"` + Stress []StressResult `json:"stress,omitempty" yaml:"stress,omitempty"` + Curves map[string][]float64 `json:"curves,omitempty" yaml:"curves,omitempty"` // 指标名 → 时间序列(渲染为 sparkline) } // StressResult 单项压力测试结果 @@ -56,8 +58,8 @@ type ConfigSummary struct { // Builder 报告构建器 type Builder struct { - report *Report - phase *PhaseResult + report *Report + phase *PhaseResult } // NewBuilder 创建报告构建器 @@ -84,13 +86,13 @@ func (b *Builder) AddHost(h HostResult) { } } -// AddStressResult 为指定 IP 添加压力测试结果 -func (b *Builder) AddStressResult(ip string, sr StressResult) { +// AddStressResult 为指定设备(MAC)添加压力测试结果 +func (b *Builder) AddStressResult(mac string, sr StressResult) { if b.phase == nil { return } for i := range b.phase.Hosts { - if b.phase.Hosts[i].IP == ip { + if b.phase.Hosts[i].MAC == mac { b.phase.Hosts[i].Stress = append(b.phase.Hosts[i].Stress, sr) return } @@ -188,14 +190,10 @@ func (r *Report) ToText() string { continue } if phase.Phase == "SSH 登录" && !h.SSHLogin { - sb.WriteString(fmt.Sprintf(" ✗ %-15s %s\n", h.IP, h.SSHErr)) + sb.WriteString(fmt.Sprintf(" ✗ %-19s %-15s %s\n", h.MAC, h.IP, h.SSHErr)) continue } - icon := "✓" - if phase.Phase == "SSH 登录" { - icon = "✓" - } - sb.WriteString(fmt.Sprintf(" %s %-15s\n", icon, h.IP)) + sb.WriteString(fmt.Sprintf(" ✓ %-19s %-15s\n", h.MAC, h.IP)) } } @@ -204,7 +202,7 @@ func (r *Report) ToText() string { if len(h.Stress) == 0 { continue } - sb.WriteString(fmt.Sprintf(" ┌─ %s\n", h.IP)) + sb.WriteString(fmt.Sprintf(" ┌─ %s (%s)\n", h.MAC, h.IP)) for _, s := range h.Stress { icon := "✓" if s.Status != "pass" { @@ -260,3 +258,22 @@ func DefaultReportPath() string { timestamp := time.Now().Format("20060102-150405") return filepath.Join(".", "reports", fmt.Sprintf("report-%s.txt", timestamp)) } + +// ============================ +// 渲染 + 打印便捷方法 +// ============================ + +// PrintTo 使用 80mm 模板渲染并打印 +// printer 为 nil 时仅返回渲染文本 +func (r *Report) PrintTo(printer Printer) (string, error) { + text, err := r.Render80mm() + if err != nil { + return "", err + } + if printer != nil { + if err := printer.Print(text); err != nil { + return text, fmt.Errorf("打印失败: %w", err) + } + } + return text, nil +} diff --git a/auto-check/pkg/report/template.go b/auto-check/pkg/report/template.go new file mode 100644 index 0000000..c97a3b8 --- /dev/null +++ b/auto-check/pkg/report/template.go @@ -0,0 +1,161 @@ +package report + +import ( + "fmt" + "math" + "strings" + "text/template" + "time" +) + +// ============================ +// 模板渲染(80mm 热敏纸适配) +// ============================ + +// TemplateConfig 模板渲染配置 +type TemplateConfig struct { + Width int // 行宽(字符数),80mm 热敏纸建议 42 +} + +// DefaultTemplateConfig 默认配置(80mm 热敏纸) +func DefaultTemplateConfig() TemplateConfig { + return TemplateConfig{Width: 42} +} + +// RenderTemplate 使用 Go template 渲染报告 +// tpl 为模板内容,data 为报告数据 +func RenderTemplate(tpl string, data interface{}, cfg TemplateConfig) (string, error) { + if cfg.Width <= 0 { + cfg.Width = 42 + } + + funcMap := template.FuncMap{ + // 格式化辅助 + "timeFmt": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") }, + // 字符串截断/填充到固定宽度 + "trunc": func(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return string(r) + } + return string(r[:n-1]) + "…" + }, + // 左侧填充(右对齐) + "padRight": func(s string, n int) string { + r := []rune(s) + if len(r) >= n { + return string(r[:n]) + } + return s + strings.Repeat(" ", n-len(r)) + }, + // 分隔线 + "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) + if err != nil { + return "", fmt.Errorf("解析模板失败: %w", err) + } + + var sb strings.Builder + if err := t.Execute(&sb, data); err != nil { + return "", fmt.Errorf("渲染模板失败: %w", err) + } + 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() +} diff --git a/auto-check/pkg/report/template_embed.go b/auto-check/pkg/report/template_embed.go new file mode 100644 index 0000000..8cd39de --- /dev/null +++ b/auto-check/pkg/report/template_embed.go @@ -0,0 +1,35 @@ +package report + +import "embed" + +//go:embed templates/*.tmpl +var templateFS embed.FS + +// embeddedTemplates 内嵌模板列表 +var embeddedTemplates = []string{ + "templates/report_80mm.tmpl", // 80mm 热敏纸文本报告 +} + +// Render80mm 渲染 80mm 热敏纸文本报告 +func (r *Report) Render80mm() (string, error) { + return r.renderTemplate("templates/report_80mm.tmpl", DefaultTemplateConfig()) +} + +// Render80mmWithConfig 使用自定义宽度渲染 +func (r *Report) Render80mmWithConfig(cfg TemplateConfig) (string, error) { + return r.renderTemplate("templates/report_80mm.tmpl", cfg) +} + +// renderTemplate 从内嵌模板渲染 +func (r *Report) renderTemplate(name string, cfg TemplateConfig) (string, error) { + data, err := templateFS.ReadFile(name) + if err != nil { + return "", err + } + return RenderTemplate(string(data), r, cfg) +} + +// TemplateNames 返回可用模板列表 +func TemplateNames() []string { + return embeddedTemplates +} diff --git a/auto-check/pkg/report/templates/report_80mm.tmpl b/auto-check/pkg/report/templates/report_80mm.tmpl new file mode 100644 index 0000000..850e07d --- /dev/null +++ b/auto-check/pkg/report/templates/report_80mm.tmpl @@ -0,0 +1,27 @@ +{{ hr '═' }} + auto-check 检测报告 +{{ hr '═' }} + 生成时间: {{ timeFmt .StartTime }} + 总耗时: {{ .Duration }} +{{ "" }} +── 配置 ── + 网段: {{ .Config.CIDR }} + SSH: {{ .Config.User }}@:{{ .Config.Port }} + 压测: {{ .Config.StressTypes }} + 并发: {{ .Config.Concurrency }} +{{ "" }} +{{ range .Phases }}{{ hr '─' }} + {{ .Phase }} [{{ .Status }}] + {{ .Summary }} +{{ hr '─' }} +{{ if eq .Phase "设备发现" }}{{ range .Hosts }}{{ if .Alive }}✓{{ else }}✗{{ end }} {{ padRight .MAC 19 }} {{ padRight .IP 15 }} +{{ end }}{{ end }}{{ if eq .Phase "SSH 登录" }}{{ range .Hosts }}{{ if .SSHLogin }}✓{{ else }}✗{{ end }} {{ padRight .MAC 19 }} {{ padRight .IP 15 }} {{ .SSHErr }} +{{ end }}{{ end }}{{ if eq .Phase "压力测试" }}{{ range .Hosts }}{{ padRight .MAC 19 }} {{ padRight .IP 15 }} +{{ range .Stress }}{{ if eq .Status "pass" }}✓{{ else }}✗{{ end }} {{ padRight .Type 12 }} {{ .Status }} {{ .Duration }} +{{ if .Error }} ERR: {{ trunc .Error 34 }}{{ end }}{{ end }}{{ range $name, $data := .Curves }} [{{ $name }}] {{ curveSummary $data }} + {{ sparkline $data 38 }} +{{ end }}{{ end }}{{ end }}{{ end }} +{{ "" }} +{{ hr '═' }} + 报告结束 +{{ hr '═' }} diff --git a/auto-check/pkg/stress/runner.go b/auto-check/pkg/stress/runner.go index e36d3d3..e58017b 100644 --- a/auto-check/pkg/stress/runner.go +++ b/auto-check/pkg/stress/runner.go @@ -5,21 +5,27 @@ import ( "strings" "time" + "auto-check/pkg/config" + "auto-check/pkg/sshclient" + "golang.org/x/crypto/ssh" ) +// Results 测试结果表(IP → 报告),包级公开 +var Results = make(map[string]*Report) + // Runner 压力测试调度器 type Runner struct { - Config Config - Types []TestType + Config Config + Types []TestType Executor Executor } // NewRunner 创建调度器 func NewRunner(cfg Config, types []TestType, executor Executor) *Runner { return &Runner{ - Config: cfg, - Types: types, + Config: cfg, + Types: types, Executor: executor, } } @@ -133,6 +139,62 @@ func (r *Runner) RunAll(clients map[string]*ssh.Client) []*Report { return reports } +// IsPassed 报告是否通过 +func IsPassed(rpt *Report) bool { + return rpt != nil && rpt.Failed == 0 && rpt.Errors == 0 +} + +// Status 报告状态字符串:pass / fail / untested +func Status(rpt *Report) string { + if rpt == nil { + return "untested" + } + if IsPassed(rpt) { + return "pass" + } + return "fail" +} + +// ParseTypes 逗号分隔字符串 → TestType 列表 +func ParseTypes(s string) []TestType { + var types []TestType + for _, t := range strings.Split(s, ",") { + if t = strings.TrimSpace(t); t != "" { + types = append(types, TestType(t)) + } + } + return types +} + +// TestDevice 对单台设备执行 SSH 登录 + 压力测试(业务入口) +func TestDevice(ip string, cfg config.Config) *Report { + client := sshclient.NewClient(cfg.SSH.User, cfg.SSH.Password, cfg.SSH.KeyFile, cfg.SSH.Port, cfg.Scan.Timeout) + + conn, err := client.Connect(ip) + if err != nil { + fmt.Printf(" [测试] %s SSH 连接失败: %v\n", ip, err) + return NewSSHFailReport(ip, err) + } + defer conn.Close() + + return NewRunner( + Config{Duration: cfg.Stress.Duration, Threads: cfg.Stress.Threads, DiskSizeMB: 1024, TempLogInt: 10 * time.Second}, + ParseTypes(cfg.Stress.Types), + client, + ).Run(conn, ip) +} + +// NewSSHFailReport 构造 SSH 连接失败报告 +func NewSSHFailReport(ip string, err error) *Report { + return &Report{ + IP: ip, + StartTime: time.Now(), + Results: []Result{{Type: "ssh", Status: "fail", Error: err.Error()}}, + Failed: 1, + } +} + +// min 取较小值 func min(a, b int) int { if a < b { return a diff --git a/auto-check/pkg/stress/types.go b/auto-check/pkg/stress/types.go index 36a67f4..7aed7e7 100644 --- a/auto-check/pkg/stress/types.go +++ b/auto-check/pkg/stress/types.go @@ -24,8 +24,8 @@ const ( TestFull TestType = "full" // CPU+内存+磁盘IO 三合一 // 监控(自动附加) - MonitorTemp TestType = "temp" // lm-sensors 温度监控 - MonitorDmesg TestType = "dmesg" // dmesg 内核报错监控 + MonitorTemp TestType = "temp" // lm-sensors 温度监控 + MonitorDmesg TestType = "dmesg" // dmesg 内核报错监控 ) // ============================ @@ -34,12 +34,12 @@ const ( // Config 压力测试配置 type Config struct { - Duration time.Duration // 总持续时间 - Threads int // CPU/内存线程数 - MemSizeMB int // 内存测试大小 (MB),0=自动(可用60%) - DiskSizeMB int // 磁盘测试大小 (MB),0=默认1024 - DiskDir string // 磁盘测试目录,空=自动临时目录 - TempLogInt time.Duration // 温度采样间隔,0=10s + Duration time.Duration // 总持续时间 + Threads int // CPU/内存线程数 + MemSizeMB int // 内存测试大小 (MB),0=自动(可用60%) + DiskSizeMB int // 磁盘测试大小 (MB),0=默认1024 + DiskDir string // 磁盘测试目录,空=自动临时目录 + TempLogInt time.Duration // 温度采样间隔,0=10s } // DefaultConfig 默认配置 diff --git a/auto-check/pkg/workflow/loop.go b/auto-check/pkg/workflow/loop.go deleted file mode 100644 index 1f7bb84..0000000 --- a/auto-check/pkg/workflow/loop.go +++ /dev/null @@ -1,279 +0,0 @@ -package workflow - -import ( - "fmt" - "strings" - "time" - - "auto-check/pkg/discovery" - "auto-check/pkg/report" - "auto-check/pkg/sshclient" - "auto-check/pkg/stress" -) - -// ============================ -// 状态常量 -// ============================ - -const ( - StatusUntested = "untested" // 未测试 - StatusPass = "pass" // 通过 - StatusFail = "fail" // 失败 -) - -// ============================ -// 设备(MAC 为唯一标识) -// ============================ - -// Device 设备信息 -type Device struct { - MAC string - IP string - Host discovery.Host -} - -// ============================ -// 永久循环工作流 -// ============================ - -// Loop 永久循环:每间隔执行一轮 -// 每轮: 扫描设备 → 增量测试 → 打印状态变化 -func Loop(ctx *Context) error { - interval := ctx.Config.Workflow.Interval - if interval <= 0 { - interval = 10 * time.Second - } - - fmt.Printf("══ 工作流启动(永久循环,间隔 %v,设备以 MAC 唯一标识)══\n\n", interval) - - // 第一轮立即执行 - round(ctx) - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - fmt.Println("══ 工作流已停止 ══") - return nil - case <-ticker.C: - round(ctx) - } - } -} - -// round 执行一轮 -func round(ctx *Context) { - now := time.Now().Format("15:04:05") - fmt.Printf("════ 轮次开始 [%s] ════\n", now) - - // ── 1. 扫描设备,更新设备列表 map(MAC 为 key)── - scanDevices(ctx) - - // ── 2. 遍历设备: 结果为空或失败 → 开始测试,保存报告和结果 ── - testPending(ctx) - - // ── 3. 遍历打印: 状态变化才重新打印 ── - printStatus(ctx) -} - -// scanDevices 扫描并更新设备列表 -// 设备身份 = MAC;IP 变化视为同一设备(更新 IP),IP 被复用则移除旧设备 -func scanDevices(ctx *Context) { - scanner := discovery.NewScanner( - ctx.Config.Scan.CIDR, - ctx.Config.SSH.Port, - ctx.Config.Scan.Timeout, - ctx.Config.Scan.Concurrency, - ) - hosts := scanner.Scan() - - // 本轮扫描到的 IP → MAC 映射 - ipToMAC := make(map[string]string, len(hosts)) - for _, h := range hosts { - ipToMAC[h.IP.String()] = h.MAC - } - - // 1. 处理 IP 被复用:旧设备的 IP 现在属于不同 MAC → 移除 - for mac, dev := range ctx.Devices { - if newMAC, ok := ipToMAC[dev.IP]; ok && newMAC != mac { - delete(ctx.Devices, mac) - delete(ctx.Results, mac) - delete(ctx.Printed, mac) - fmt.Printf(" [-] IP 复用: %s 现属于 %s,移除旧设备 %s\n", dev.IP, newMAC, mac) - } - } - - // 2. 合并扫描结果 - added, updated := 0, 0 - for _, h := range hosts { - mac := h.MAC - ip := h.IP.String() - if mac == "" { - // 无法获取 MAC:降级用 IP 做 key,标记为临时 - mac = "tmp:" + ip - } - - if dev, ok := ctx.Devices[mac]; ok { - // 已存在:仅更新 IP(设备可能换了地址) - if dev.IP != ip { - dev.IP = ip - dev.Host = h - updated++ - } - } else { - // 新设备 - ctx.Devices[mac] = &Device{MAC: mac, IP: ip, Host: h} - added++ - } - } - - fmt.Printf(" [扫描] 设备 %d 台(新增 %d,IP更新 %d)\n", len(ctx.Devices), added, updated) -} - -// testPending 对未测试或失败设备执行测试 -func testPending(ctx *Context) { - for mac, dev := range ctx.Devices { - // 跳过: 已有结果且通过 - if rpt, ok := ctx.Results[mac]; ok && rpt != nil && rpt.Failed == 0 && rpt.Errors == 0 { - continue - } - - // 需要测试(结果为空 或 失败) - fmt.Printf(" [测试] %s (%s) 开始压测...\n", dev.IP, dev.MAC) - rpt := testDevice(ctx, dev.IP) - ctx.Results[mac] = rpt - - // 保存测试报告 - saveDeviceReport(dev, rpt, ctx.Config.Report.Path) - } -} - -// testDevice 对单台设备执行 SSH 登录 + 压力测试 -func testDevice(ctx *Context, ip string) *stress.Report { - cfg := ctx.Config - - // SSH 连接 - client := sshclient.NewClient( - cfg.SSH.User, cfg.SSH.Password, cfg.SSH.KeyFile, - cfg.SSH.Port, cfg.Scan.Timeout, - ) - conn, err := client.Connect(ip) - if err != nil { - fmt.Printf(" [测试] %s SSH 连接失败: %v\n", ip, err) - return &stress.Report{ - IP: ip, - StartTime: time.Now(), - Results: []stress.Result{{ - Type: "ssh", - Status: "fail", - Error: err.Error(), - }}, - Failed: 1, - } - } - defer conn.Close() - - // 执行压力测试 - runner := stress.NewRunner( - stress.Config{ - Duration: cfg.Stress.Duration, - Threads: cfg.Stress.Threads, - DiskSizeMB: 1024, - TempLogInt: 10 * time.Second, - }, - mapStressTypes(cfg.Stress.Types), - client, - ) - return runner.Run(conn, ip) -} - -// printStatus 打印状态变化(仅当首次或状态变化时) -func printStatus(ctx *Context) { - for mac, rpt := range ctx.Results { - dev := ctx.Devices[mac] - label := devLabel(dev) - status := statusOf(rpt) - // 上次状态为空 或 与当前不一致 → 打印并记录 - if prev, ok := ctx.Printed[mac]; !ok || prev != status { - icon := map[string]string{ - StatusPass: "✓", - StatusFail: "✗", - }[status] - fmt.Printf(" [状态] %s %s → %s\n", label, icon, status) - ctx.Printed[mac] = status - } - } - - // 未测试设备(无结果): 首次打印一次 untested - for mac, dev := range ctx.Devices { - if _, hasResult := ctx.Results[mac]; !hasResult { - if _, printed := ctx.Printed[mac]; !printed { - fmt.Printf(" [状态] %s ○ → %s\n", devLabel(dev), StatusUntested) - ctx.Printed[mac] = StatusUntested - } - } - } -} - -// devLabel 设备显示名: MAC (IP) -func devLabel(dev *Device) string { - if dev == nil { - return "?" - } - if strings.HasPrefix(dev.MAC, "tmp:") { - return dev.IP + " (无MAC)" - } - return fmt.Sprintf("%s (%s)", dev.MAC, dev.IP) -} - -// mapStressTypes 逗号分隔字符串 → TestType 列表 -func mapStressTypes(s string) []stress.TestType { - var types []stress.TestType - for _, t := range strings.Split(s, ",") { - t = strings.TrimSpace(t) - types = append(types, stress.TestType(t)) - } - return types -} - -// statusOf 从测试报告得出状态 -func statusOf(rpt *stress.Report) string { - if rpt == nil { - return StatusUntested - } - if rpt.Failed > 0 || rpt.Errors > 0 { - return StatusFail - } - return StatusPass -} - -// saveDeviceReport 保存单台设备的测试报告(文件名含 MAC 和 IP) -func saveDeviceReport(dev *Device, rpt *stress.Report, dir string) { - if dir == "" { - dir = "reports" - } - ts := time.Now().Format("20060102-150405") - name := strings.ReplaceAll(dev.MAC, ":", "") - path := fmt.Sprintf("%s/report-%s-%s-%s.txt", strings.TrimRight(dir, "/"), name, dev.IP, ts) - - r := report.NewBuilder(report.ConfigSummary{}) - r.StartPhase("压力测试") - for _, res := range rpt.Results { - r.AddStressResult(dev.IP, report.StressResult{ - Type: string(res.Type), - Status: res.Status, - Duration: res.Duration.String(), - Error: res.Error, - Output: res.Output, - }) - } - r.EndPhase(rpt.Summary()) - - if err := r.Build().SaveFile(path, "text"); err != nil { - fmt.Printf(" [报告] %s 保存失败: %v\n", dev.IP, err) - } else { - fmt.Printf(" [报告] %s 已保存: %s\n", dev.IP, path) - } -} diff --git a/auto-check/pkg/workflow/workflow.go b/auto-check/pkg/workflow/workflow.go index 46a5900..af63604 100644 --- a/auto-check/pkg/workflow/workflow.go +++ b/auto-check/pkg/workflow/workflow.go @@ -6,161 +6,96 @@ import ( "time" "auto-check/pkg/config" + "auto-check/pkg/discovery" + "auto-check/pkg/report" "auto-check/pkg/stress" ) -// ============================ -// Context — 阶段间共享数据 -// ============================ - -// Context 工作流上下文,保存阶段间传递的数据 -type Context struct { - base context.Context // 可取消上下文 - - // 配置(由入口设置) - Config config.Config - - // 循环模式下的状态数据(key 均为设备 MAC) - Devices map[string]*Device // 设备列表(MAC → 设备) - Results map[string]*stress.Report // 测试结果(nil/失败=需测试) - Printed map[string]string // 上次打印的测试状态 -} - -// NewContext 创建上下文 -func NewContext() *Context { - return &Context{ - base: context.Background(), - Devices: make(map[string]*Device), - Results: make(map[string]*stress.Report), - Printed: make(map[string]string), - } -} - -// WithCancel 支持取消 -func (c *Context) WithCancel() (context.CancelFunc, error) { - ctx, cancel := context.WithCancel(c.base) - c.base = ctx - return cancel, nil -} - -// Done 返回取消信号 -func (c *Context) Done() <-chan struct{} { - return c.base.Done() -} - -// Err 返回取消原因 -func (c *Context) Err() error { - return c.base.Err() -} - -// ============================ -// Stage — 阶段接口 -// ============================ - -// Stage 工作流阶段 -type Stage interface { - Name() string - Execute(ctx *Context) error -} - -// ============================ -// Workflow — 流水线调度器 -// ============================ - -// OnStageError 阶段失败处理策略 -type OnStageError int - -const ( - // StopOnError 失败即中断整个工作流 - StopOnError OnStageError = iota - // ContinueOnError 失败继续执行后续阶段 - ContinueOnError -) - -// StageResult 阶段执行结果 -type StageResult struct { - Stage string - Start time.Time - End time.Time - Duration time.Duration - Err error -} - -// Workflow Pipeline 工作流 +// Workflow 固定工作流:扫描 → 压测 → 打印状态,按间隔循环 +// 状态数据由各包自持(discovery/stress/report),workflow 直接调用其包级函数 type Workflow struct { - stages []Stage - onError OnStageError - results []StageResult - startTime time.Time + cfg config.Config + base context.Context + cancel context.CancelFunc } -// New 创建空工作流 -func New() *Workflow { - return &Workflow{ - onError: StopOnError, +// New 创建工作流 +func New(cfg config.Config) *Workflow { + base, cancel := context.WithCancel(context.Background()) + return &Workflow{cfg: cfg, base: base, cancel: cancel} +} + +// Start 启动固定工作流循环(永久运行,直至取消) +func (w *Workflow) Start() error { + interval := w.cfg.Workflow.Interval + if interval <= 0 { + interval = 10 * time.Second } -} -// SetOnError 设置失败策略 -func (w *Workflow) SetOnError(policy OnStageError) *Workflow { - w.onError = policy - return w -} + fmt.Printf("══ 工作流启动(间隔 %v,设备以 IP 为 key)══\n\n", interval) + w.round() // 首轮立即执行 -// AddStage 追加阶段(串行) -func (w *Workflow) AddStage(stage Stage) *Workflow { - w.stages = append(w.stages, stage) - return w -} + ticker := time.NewTicker(interval) + defer ticker.Stop() -// Run 依次执行所有阶段 -func (w *Workflow) Run(ctx *Context) error { - w.startTime = time.Now() - fmt.Printf("══ 工作流启动 (%d 个阶段) ══\n\n", len(w.stages)) - - for i, stage := range w.stages { - // 检查是否被取消 + for { select { - case <-ctx.Done(): - fmt.Printf(" [!] 工作流已取消,停止于阶段 %d/%d\n", i+1, len(w.stages)) - return ctx.Err() - default: - } - - fmt.Printf("════ 阶段 %d/%d: %s ════\n", i+1, len(w.stages), stage.Name()) - start := time.Now() - err := stage.Execute(ctx) - duration := time.Since(start) - - result := StageResult{ - Stage: stage.Name(), - Start: start, - End: time.Now(), - Duration: duration, - Err: err, - } - w.results = append(w.results, result) - - if err != nil { - fmt.Printf(" [✗] 阶段失败: %v\n", err) - switch w.onError { - case StopOnError: - fmt.Printf("══ 工作流中止(策略: 失败即停止)══\n") - return err - case ContinueOnError: - fmt.Printf(" [!] 继续执行后续阶段(策略: 失败继续)\n\n") - continue - } - } else { - fmt.Printf(" [✓] 阶段完成 (%v)\n\n", duration.Round(time.Millisecond)) + case <-w.base.Done(): + fmt.Println("══ 工作流已停止 ══") + return nil + case <-ticker.C: + w.round() } } - - fmt.Printf("══ 工作流完成,总耗时 %v ══\n", time.Since(w.startTime).Round(time.Millisecond)) - return nil } -// Results 阶段执行结果列表 -func (w *Workflow) Results() []StageResult { - return w.results +// Stop 停止工作流 +func (w *Workflow) Stop() { + w.cancel() +} + +// round 单轮:扫描 → 压测 → 打印状态变化 +func (w *Workflow) round() { + fmt.Printf("════ 轮次开始 [%s] ════\n", time.Now().Format("15:04:05")) + + // 1. 扫描(discovery 包),更新设备表 + cfg := w.cfg + discovery.NewScanner(cfg.Scan.CIDR, cfg.Scan.Timeout, cfg.Scan.Concurrency).Discover() + fmt.Printf(" [扫描] 设备 %d 台\n", len(discovery.Devices)) + + // 2. 压测未通过设备(stress.TestDevice 执行,report 保存+打印) + for ip, dev := range discovery.Devices { + if stress.IsPassed(stress.Results[ip]) { + continue // 已通过,跳过 + } + fmt.Printf(" [测试] %s (%s) 开始压测...\n", ip, dev.MAC) + rpt := stress.TestDevice(ip, w.cfg) + stress.Results[ip] = rpt + report.SaveAndPrintDeviceReport(&dev, rpt, cfg.Report.Path, cfg.Report.Print) + } + + // 3. 打印状态变化(首次或与上次不一致) + for ip, rpt := range stress.Results { + dev, ok := discovery.Devices[ip] + if !ok { + continue // 设备本轮缺席,状态保留等它回来 + } + status := stress.Status(rpt) + if prev, ok := report.Printed[ip]; !ok || prev != status { + fmt.Printf(" [状态] %s %s → %s\n", dev.Label(), statusIcon(status), status) + report.Printed[ip] = status + } + } +} + +// statusIcon 图标 +func statusIcon(s string) string { + switch s { + case "pass": + return "✓" + case "fail": + return "✗" + default: + return "○" + } }