重构 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

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