重构 auto-check:拆分包架构,精简 workflow 为纯调度循环

各包职责:
- config: 分类 YAML 配置
- model: Device/IP/MAC 公共类型
- discovery: 扫描 + ARP MAC 解析 + Devices 变量
- sshclient: SSH 连接
- stress: stress-ng/stressapptest 压测 + Results 变量
- report: 80mm 热敏模板/sparkline/打印 + Printed 变量
- workflow: 永久循环调度 + Start/Stop

workflow 已精简为 100 行纯编排,所有业务逻辑下沉各包
This commit is contained in:
张威33321
2026-08-11 20:58:19 +08:00
parent 19f6050147
commit 700ba91883
17 changed files with 867 additions and 516 deletions

View File

@@ -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 工作流

View File

@@ -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 无 MACARP 未解析),下轮重试\n", dev.IP)
continue
}
Devices[dev.IP] = dev
}
}
// inc IP 递增
func inc(ip net.IP) {
for j := len(ip) - 1; j >= 0; j-- {

View File

@@ -0,0 +1,19 @@
package model
import "fmt"
// ============================
// 公共类型 — 全包共用,唯一来源
// ============================
// Device 设备MAC 为唯一标识)
// 当前仅 IP/MAC 两个字段,后续按需扩展
type Device struct {
IP string // 当前 IPDHCP 可能变化)
MAC string // 物理地址,唯一标识,空 = 无法获取
}
// Label 设备显示名(用于日志/报告)
func (d Device) Label() string {
return fmt.Sprintf("%s (%s)", d.MAC, d.IP)
}

View File

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

View File

@@ -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/lp0Windows \\.\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)
}
}

View File

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

View File

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

View File

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

View File

@@ -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 '═' }}

View File

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

View File

@@ -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 默认配置

View File

@@ -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. 扫描设备,更新设备列表 mapMAC 为 key──
scanDevices(ctx)
// ── 2. 遍历设备: 结果为空或失败 → 开始测试,保存报告和结果 ──
testPending(ctx)
// ── 3. 遍历打印: 状态变化才重新打印 ──
printStatus(ctx)
}
// scanDevices 扫描并更新设备列表
// 设备身份 = MACIP 变化视为同一设备(更新 IPIP 被复用则移除旧设备
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 台(新增 %dIP更新 %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)
}
}

View File

@@ -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/reportworkflow 直接调用其包级函数
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 "○"
}
}