重构 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:
0
auto-check/.codewhale/state/subagents.v1.lock
Normal file
0
auto-check/.codewhale/state/subagents.v1.lock
Normal file
@@ -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 热敏纸行宽
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module auto-check
|
||||
|
||||
go 1.25.1
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
|
||||
@@ -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 工作流
|
||||
|
||||
@@ -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-- {
|
||||
|
||||
19
auto-check/pkg/model/model.go
Normal file
19
auto-check/pkg/model/model.go
Normal file
@@ -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)
|
||||
}
|
||||
77
auto-check/pkg/report/device.go
Normal file
77
auto-check/pkg/report/device.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
257
auto-check/pkg/report/print.go
Normal file
257
auto-check/pkg/report/print.go
Normal 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/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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
161
auto-check/pkg/report/template.go
Normal file
161
auto-check/pkg/report/template.go
Normal 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()
|
||||
}
|
||||
35
auto-check/pkg/report/template_embed.go
Normal file
35
auto-check/pkg/report/template_embed.go
Normal 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
|
||||
}
|
||||
27
auto-check/pkg/report/templates/report_80mm.tmpl
Normal file
27
auto-check/pkg/report/templates/report_80mm.tmpl
Normal 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 '═' }}
|
||||
@@ -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
|
||||
|
||||
@@ -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 默认配置
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "○"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user