package main import ( "context" "encoding/json" "flag" "fmt" "math" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/gorilla/websocket" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/mem" "gopkg.in/yaml.v3" ) var ( Version = "dev" BuildTime = "unknown" ) // ═══════════════════════════════════════════════ // Config (YAML 持久化) // ═══════════════════════════════════════════════ type ClientConfig struct { MachineName string `yaml:"machine_name"` Server string `yaml:"server"` Interval int `yaml:"interval"` ReconnectDelay int `yaml:"reconnect_delay"` } func defaultConfig() ClientConfig { return ClientConfig{ Interval: 3, ReconnectDelay: 3, } } func configPath() string { // 优先: 当前工作目录 if wd, err := os.Getwd(); err == nil { p := filepath.Join(wd, "client.yaml") if _, err := os.Stat(p); err == nil { return p } } // 其次: 可执行文件目录 if exe, err := os.Executable(); err == nil { return filepath.Join(filepath.Dir(exe), "client.yaml") } return "client.yaml" } func loadConfig() (ClientConfig, bool) { p := configPath() raw, err := os.ReadFile(p) if err != nil { return defaultConfig(), false } cfg := defaultConfig() if yaml.Unmarshal(raw, &cfg) != nil { return defaultConfig(), false } cfg.MachineName = strings.TrimSpace(cfg.MachineName) cfg.Server = strings.TrimSpace(cfg.Server) if cfg.MachineName == "" || cfg.Server == "" { return defaultConfig(), false } if cfg.Interval <= 0 { cfg.Interval = 3 } if cfg.ReconnectDelay <= 0 { cfg.ReconnectDelay = 3 } return cfg, true } func saveConfig(c ClientConfig) error { out, err := yaml.Marshal(&c) if err != nil { return err } hdr := "# Hardware Monitor Client 配置\n" hdr += "#\n" hdr += "# machine_name : 设备名称 (默认使用主机名)\n" hdr += "# server : 服务端地址 (ip:port)\n" hdr += "# interval : 采集间隔 (秒, 默认 3)\n" hdr += "# reconnect_delay : 断线重连间隔 (秒, 默认 3)\n" hdr += "#\n" hdr += "# 删除此文件可重新配置\n\n" p := configPath() os.MkdirAll(filepath.Dir(p), 0755) return os.WriteFile(p, append([]byte(hdr), out...), 0644) } // ═══════════════════════════════════════════════ // 数据结构 // ═══════════════════════════════════════════════ type MachineData struct { MachineName string `json:"machine_name"` Motherboard BoardInfo `json:"motherboard"` CPU CPUInfo `json:"cpu"` Memory MemInfo `json:"memory"` GPUs []GPUInfo `json:"gpus"` Timestamp time.Time `json:"timestamp"` } type BoardInfo struct { Manufacturer string `json:"manufacturer"` Product string `json:"product"` } type CPUInfo struct { Model string `json:"model"` CoresPhysical int `json:"cores_physical"` CoresLogical int `json:"cores_logical"` FreqMHz float64 `json:"freq_current_mhz"` Temperature *float64 `json:"temperature"` UsageTotal float64 `json:"usage_total"` UsagePerCore []float64 `json:"usage_per_core"` } type MemInfo struct { TotalBytes uint64 `json:"total_bytes"` UsedBytes uint64 `json:"used_bytes"` Percent float64 `json:"percent"` } type GPUInfo struct { Name string `json:"name"` VRAMTotalMB float64 `json:"vram_total_mb"` VRAMUsedMB float64 `json:"vram_used_mb"` Temperature float64 `json:"temperature"` LoadPercent float64 `json:"load_percent"` CoreClock float64 `json:"core_clock_mhz"` MemClock float64 `json:"mem_clock_mhz"` Power float64 `json:"power_watts"` FanSpeed float64 `json:"fan_speed"` } // ═══════════════════════════════════════════════ // 样式 (全部使用 lipgloss.Style,非 TerminalColor) // ═══════════════════════════════════════════════ var ( sAccent = lipgloss.Color("#00e5a0") sWarn = lipgloss.Color("#ffb020") sDanger = lipgloss.Color("#ff4060") sDim = lipgloss.Color("#5c6480") sFg = lipgloss.Color("#e4e8f1") sBorder = lipgloss.Color("#333852") sDimBar = lipgloss.Color("#2a2e3e") sSurface = lipgloss.Color("#181c2a") stTitle = lipgloss.NewStyle().Bold(true).Foreground(sAccent) stDim = lipgloss.NewStyle().Foreground(sDim) stFg = lipgloss.NewStyle().Foreground(sFg) stBold = lipgloss.NewStyle().Bold(true) stLabel = lipgloss.NewStyle().Foreground(sDim) stVal = lipgloss.NewStyle().Foreground(sFg) stBox = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(sBorder). Padding(0, 1) stBoxInner = lipgloss.NewStyle().Padding(0, 0) ) func pctStyle(p float64) lipgloss.Style { if p >= 80 { return lipgloss.NewStyle().Foreground(sDanger) } if p >= 60 { return lipgloss.NewStyle().Foreground(sWarn) } return lipgloss.NewStyle().Foreground(sAccent) } func tempStyle(t *float64) lipgloss.Style { if t == nil || *t <= 0 { return stFg } if *t >= 85 { return lipgloss.NewStyle().Foreground(sDanger) } if *t >= 70 { return lipgloss.NewStyle().Foreground(sWarn) } return stFg } // ═══════════════════════════════════════════════ // Bubbletea 消息 // ═══════════════════════════════════════════════ type tickMsg time.Time type dataMsg struct{ data MachineData } type wsStatusMsg struct{ online bool } type collectorReadyMsg struct{} // ═══════════════════════════════════════════════ // Model // ═══════════════════════════════════════════════ const histLen = 60 var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} type model struct { // 阶段: 0=输入, 1=监控 phase int // 输入框 nameIn textinput.Model addrIn textinput.Model focus int // 0 or 1 errMsg string // 配置 cfg ClientConfig cfgPath string // 连接状态 wsOnline bool // 通道 dataCh chan MachineData statCh chan bool doneCh chan struct{} // 关闭即停止采集 // 最新数据 cpu CPUInfo mem MemInfo gpus []GPUInfo // 历史 cpuH []float64 memH []float64 gpuLH []float64 // 终端尺寸 w, h int } func newModel(hostname string, cfg ClientConfig, loaded bool) model { // 输入框: 设备名 ni := textinput.New() ni.Placeholder = hostname ni.Prompt = " ❯ " ni.CharLimit = 64 ni.Width = 38 ni.SetValue(hostname) // 输入框: 服务端地址 ai := textinput.New() ai.Placeholder = "localhost:8080" ai.Prompt = " ❯ " ai.CharLimit = 128 ai.Width = 38 ai.SetValue("localhost:8080") p := 0 if loaded { p = 1 ni.SetValue(cfg.MachineName) ai.SetValue(cfg.Server) } else { ni.Focus() } m := model{ phase: p, nameIn: ni, addrIn: ai, cfg: cfg, cfgPath: configPath(), dataCh: make(chan MachineData, 4), statCh: make(chan bool, 2), doneCh: make(chan struct{}), cpuH: make([]float64, 0, histLen), memH: make([]float64, 0, histLen), gpuLH: make([]float64, 0, histLen), w: 100, h: 30, } return m } // ═══════════════════════════════════════════════ // Commands // ═══════════════════════════════════════════════ // listenCmd 阻塞等待 data/stat/timeout,返回对应消息 func (m model) listenCmd() tea.Cmd { dc, sc := m.dataCh, m.statCh return func() tea.Msg { select { case d := <-dc: return dataMsg{data: d} case s := <-sc: return wsStatusMsg{online: s} case <-time.After(time.Second): return tickMsg(time.Now()) } } } // startCollectorCmd 启动采集 goroutine func (m model) startCollectorCmd() tea.Cmd { cfg := m.cfg dc := m.dataCh sc := m.statCh done := m.doneCh return func() tea.Msg { go runCollector(done, cfg, dc, sc) return collectorReadyMsg{} } } // ═══════════════════════════════════════════════ // Init // ═══════════════════════════════════════════════ func (m model) Init() tea.Cmd { cmds := []tea.Cmd{tea.EnterAltScreen} if m.phase == 1 { cmds = append(cmds, m.startCollectorCmd(), m.listenCmd()) } else { cmds = append(cmds, textinput.Blink) } return tea.Batch(cmds...) } // ═══════════════════════════════════════════════ // Update // ═══════════════════════════════════════════════ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.w, m.h = msg.Width, msg.Height return m, nil case collectorReadyMsg: return m, nil case tickMsg: if m.phase == 1 { return m, m.listenCmd() } return m, nil case dataMsg: m.cpu = msg.data.CPU m.mem = msg.data.Memory m.gpus = msg.data.GPUs m.pushHistory() if m.phase == 1 { return m, m.listenCmd() } return m, nil case wsStatusMsg: m.wsOnline = msg.online if m.phase == 1 { return m, m.listenCmd() } return m, nil case tea.KeyMsg: return m.handleKey(msg) } // 非按键消息转发给 textinput (光标闪烁等) if m.phase == 0 { var cmd tea.Cmd if m.focus == 0 { m.nameIn, cmd = m.nameIn.Update(msg) } else { m.addrIn, cmd = m.addrIn.Update(msg) } return m, cmd } return m, nil } func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit } if m.phase == 0 { return m.handleInputKey(msg) } return m.handleMonitorKey(msg) } func (m model) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": return m.handleEnter() case "tab", "down": m.focus = 1 m.nameIn.Blur() m.addrIn.Focus() return m, nil case "up", "shift+tab": m.focus = 0 m.nameIn.Focus() m.addrIn.Blur() return m, nil } // 其余按键转发给当前聚焦的输入框 var cmd tea.Cmd if m.focus == 0 { m.nameIn, cmd = m.nameIn.Update(msg) } else { m.addrIn, cmd = m.addrIn.Update(msg) } return m, cmd } func (m model) handleEnter() (tea.Model, tea.Cmd) { name := strings.TrimSpace(m.nameIn.Value()) addr := strings.TrimSpace(m.addrIn.Value()) if name == "" { m.errMsg = "设备名称不能为空" return m, nil } if addr == "" { m.errMsg = "服务端地址不能为空" return m, nil } if !strings.Contains(addr, ":") { addr += ":8080" } cfg := defaultConfig() cfg.MachineName = name cfg.Server = addr m.cfg = cfg saveConfig(m.cfg) // 切换到监控阶段 m.phase = 1 m.errMsg = "" m.dataCh = make(chan MachineData, 4) m.statCh = make(chan bool, 2) m.wsOnline = false return m, tea.Batch(m.startCollectorCmd(), m.listenCmd()) } func (m model) handleMonitorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": return m, tea.Quit case "r": // 停止采集 goroutine close(m.doneCh) m.doneCh = make(chan struct{}) // 回到输入阶段 m.phase = 0 m.wsOnline = false m.nameIn.SetValue(m.cfg.MachineName) m.addrIn.SetValue(m.cfg.Server) m.focus = 0 m.nameIn.Focus() m.addrIn.Blur() return m, textinput.Blink } return m, nil } // ═══════════════════════════════════════════════ // 历史数据 // ═══════════════════════════════════════════════ func (m *model) pushHistory() { m.cpuH = appendHist(m.cpuH, m.cpu.UsageTotal) m.memH = appendHist(m.memH, m.mem.Percent) if len(m.gpus) > 0 { m.gpuLH = appendHist(m.gpuLH, m.gpus[0].LoadPercent) } } func appendHist(h []float64, v float64) []float64 { h = append(h, v) if len(h) > histLen { h = h[len(h)-histLen:] } return h } // ═══════════════════════════════════════════════ // View // ═══════════════════════════════════════════════ func (m model) View() string { if m.phase == 0 { return m.viewInput() } return m.viewMonitor() } // ── 输入界面 ── func (m model) viewInput() string { title := stTitle.Render(" HARDWARE MONITOR CLIENT ") sub := stDim.Render("输入设备信息以连接服务端") div := stDim.Render(strings.Repeat("─", 46)) content := lipgloss.JoinVertical(lipgloss.Left, title, sub, div, "", stLabel.Render(" 设备名称"), m.nameIn.View(), "", stLabel.Render(" 服务端地址"), m.addrIn.View(), ) if m.errMsg != "" { content += "\n" + lipgloss.NewStyle().Foreground(sDanger).Render(" ✗ "+m.errMsg) } content += "\n" + stDim.Render(" Tab 切换 · Enter 确认 · Ctrl+C 退出") box := stBox.Render(content) return lipgloss.Place(m.w, m.h, lipgloss.Center, lipgloss.Center, box) } // ── 监控主界面 ── func (m model) viewMonitor() string { tw := m.w if tw < 60 { tw = 60 } // ── 顶栏 ── wsDot := "○" wsTxt := "未连接" wsC := lipgloss.NewStyle().Foreground(sDanger) if m.wsOnline { wsDot = "●" wsTxt = "已连接" wsC = lipgloss.NewStyle().Foreground(sAccent) } header := lipgloss.JoinHorizontal(lipgloss.Left, stTitle.Render(" SYSTEM MONITOR "), " ", wsC.Render(wsDot+" "+wsTxt), stDim.Render(" │ "+m.cfg.MachineName+" │ "), stDim.Render(time.Now().Format("15:04:05")), ) sep := stDim.Render(strings.Repeat("─", tw)) // ── 列宽计算 ── gap := 2 var leftW, rightW int if tw >= 100 { leftW = (tw - gap) / 2 rightW = tw - gap - leftW } else { leftW = tw rightW = tw gap = 0 } innerL := leftW - 4 innerR := rightW - 4 // ── 左列: CPU + History ── cpuBox := m.viewCPUBox(innerL) histBox := m.viewHistBox(innerL) leftCol := lipgloss.JoinVertical(lipgloss.Left, cpuBox, "", histBox) // ── 右列: Memory + GPU ── memBox := m.viewMemBox(innerR) gpuBox := m.viewGPUBox(innerR) rightCol := lipgloss.JoinVertical(lipgloss.Left, memBox, "", gpuBox) // ── 合并列 ── var mainContent string if gap > 0 { mainContent = lipgloss.JoinHorizontal(lipgloss.Top, leftCol, strings.Repeat(" ", gap), rightCol, ) } else { mainContent = lipgloss.JoinVertical(lipgloss.Left, leftCol, "", rightCol) } // ── 底栏 ── footer := stDim.Render(" q 退出 · r 重新配置 · 连接: " + m.cfg.Server) full := lipgloss.JoinVertical(lipgloss.Left, header, sep, "", mainContent, "", sep, footer, ) return full } // ── CPU 盒子 ── func (m model) viewCPUBox(innerW int) string { c := m.cpu pct := c.UsageTotal // 标题 modelName := c.Model maxNameW := innerW - 10 if len(modelName) > maxNameW && maxNameW > 3 { modelName = modelName[:maxNameW-1] + "…" } titleLine := stTitle.Render("CPU") + " " + stFg.Render(modelName) // 百分比进度条 barW := innerW - 8 if barW < 10 { barW = 10 } bar := renderBar(pct, barW) pctStr := pctStyle(pct).Bold(true).Render(fmt.Sprintf("%5.1f%%", pct)) barLine := lipgloss.JoinHorizontal(lipgloss.Center, bar, " ", pctStr) // 信息行 coresStr := fmt.Sprintf("%dC/%dT", c.CoresPhysical, c.CoresLogical) freqStr := fmt.Sprintf("%.0f MHz", c.FreqMHz) tempStr := tempText(c.Temperature) infoLine := lipgloss.JoinHorizontal(lipgloss.Left, stLabel.Render("核心 "), stVal.Render(coresStr), " ", stLabel.Render("频率 "), stVal.Render(freqStr), " ", stLabel.Render("温度 "), tempStyle(c.Temperature).Render(tempStr), ) // 核心热力图 coreLine := m.viewCoreHeatmap(innerW) body := lipgloss.JoinVertical(lipgloss.Left, titleLine, "", barLine, infoLine, "", coreLine, ) return stBox.Width(innerW + 4).Render(body) } func (m model) viewCoreHeatmap(innerW int) string { cores := m.cpu.UsagePerCore if len(cores) == 0 { return stDim.Render(" 核心占用 (无数据)") } lbl := stDim.Render(" 核心 ") graphW := innerW - lipgloss.Width(lbl) if graphW < 1 { graphW = 1 } var sb strings.Builder sb.WriteString(lbl) for i, p := range cores { if i >= graphW { break } idx := int(p / 100 * float64(len(sparkChars)-1)) idx = clamp(idx, 0, len(sparkChars)-1) sb.WriteString(pctStyle(p).Render(string(sparkChars[idx]))) } // 补点 if len(cores) < graphW { dim := lipgloss.NewStyle().Foreground(sDimBar) sb.WriteString(dim.Render(strings.Repeat("·", graphW-len(cores)))) } return sb.String() } // ── Memory 盒子 ── func (m model) viewMemBox(innerW int) string { pct := m.mem.Percent titleLine := stTitle.Render("MEMORY") // 百分比 pctStr := pctStyle(pct).Bold(true).Render(fmt.Sprintf("%.1f%%", pct)) // 进度条 barW := innerW - 8 if barW < 10 { barW = 10 } bar := renderBar(pct, barW) barLine := lipgloss.JoinHorizontal(lipgloss.Center, bar, " ", pctStr) // 详情 usedGB := fmtBytes(m.mem.UsedBytes) totalGB := fmtBytes(m.mem.TotalBytes) detailLine := lipgloss.JoinHorizontal(lipgloss.Left, stLabel.Render("已用 "), stVal.Render(usedGB), " ", stLabel.Render("总计 "), stVal.Render(totalGB), ) body := lipgloss.JoinVertical(lipgloss.Left, titleLine, "", pctStr, barLine, "", detailLine, ) return stBox.Width(innerW + 4).Render(body) } // ── GPU 盒子 ── func (m model) viewGPUBox(innerW int) string { titleLine := stTitle.Render("GPU") if len(m.gpus) == 0 { body := lipgloss.JoinVertical(lipgloss.Left, titleLine, "", stDim.Render(" 未检测到 GPU"), ) return stBox.Width(innerW + 4).Render(body) } var blocks []string blocks = append(blocks, titleLine, "") for i, g := range m.gpus { if i > 0 { blocks = append(blocks, stDim.Render(" "+strings.Repeat("·", innerW-4))) } blocks = append(blocks, m.viewSingleGPU(g, innerW)) } body := lipgloss.JoinVertical(lipgloss.Left, blocks...) return stBox.Width(innerW + 4).Render(body) } func (m model) viewSingleGPU(g GPUInfo, w int) string { // 名称 nm := g.Name if len(nm) > w-2 && w > 5 { nm = nm[:w-5] + "…" } nameLine := stBold.Render(" " + nm) // 统计行 loadC := pctStyle(g.LoadPercent) tmpC := tempStyle(&g.Temperature) statsLine := lipgloss.JoinHorizontal(lipgloss.Left, stLabel.Render(" 负载 "), loadC.Render(fmt.Sprintf("%.0f%%", g.LoadPercent)), " ", stLabel.Render("温度 "), tmpC.Render(fmt.Sprintf("%.0f°C", g.Temperature)), " ", stLabel.Render("功耗 "), stVal.Render(fmt.Sprintf("%.0fW", g.Power)), ) // 频率行 clockLine := lipgloss.JoinHorizontal(lipgloss.Left, stLabel.Render(" 核心 "), stVal.Render(fmt.Sprintf("%.0f MHz", g.CoreClock)), " ", stLabel.Render("显存 "), stVal.Render(fmt.Sprintf("%.0f MHz", g.MemClock)), ) // VRAM vramLine := "" if g.VRAMTotalMB > 0 { vpct := g.VRAMUsedMB / g.VRAMTotalMB * 100 barW := w - 20 if barW < 6 { barW = 6 } vbar := renderBar(vpct, barW) usedGB := fmt.Sprintf("%.1f", g.VRAMUsedMB/1024) totalGB := fmt.Sprintf("%.1f", g.VRAMTotalMB/1024) vramLine = lipgloss.JoinHorizontal(lipgloss.Left, stLabel.Render(" 显存 "), pctStyle(vpct).Render(usedGB), stVal.Render("/"+totalGB+" GB "), vbar, ) } return lipgloss.JoinVertical(lipgloss.Left, nameLine, statsLine, clockLine, vramLine, ) } // ── History 盒子 ── func (m model) viewHistBox(innerW int) string { titleLine := stTitle.Render("HISTORY") + stDim.Render(" (最近 60 采样)") graphW := innerW - 8 if graphW < 8 { graphW = 8 } sparkCPU := renderSparkRow("CPU ", m.cpuH, graphW, lipgloss.NewStyle().Foreground(sAccent)) sparkMEM := renderSparkRow("MEM ", m.memH, graphW, lipgloss.NewStyle().Foreground(sWarn)) sparkGPU := renderSparkRow("GPU ", m.gpuLH, graphW, lipgloss.NewStyle().Foreground(sAccent)) body := lipgloss.JoinVertical(lipgloss.Left, titleLine, "", sparkCPU, sparkMEM, sparkGPU, ) return stBox.Width(innerW + 4).Render(body) } // ═══════════════════════════════════════════════ // 渲染工具函数 // ═══════════════════════════════════════════════ func renderBar(pct float64, width int) string { pct = clampF(pct, 0, 100) filled := int(math.Round(float64(width) * pct / 100)) filled = clamp(filled, 0, width) style := pctStyle(pct) dimStyle := lipgloss.NewStyle().Foreground(sDimBar) return style.Render(strings.Repeat("█", filled)) + dimStyle.Render(strings.Repeat("░", width-filled)) } func renderSparkRow(label string, data []float64, graphW int, style lipgloss.Style) string { lbl := stDim.Render(" " + label) gw := graphW - 2 if gw < 4 { gw = 4 } dimDot := lipgloss.NewStyle().Foreground(sDimBar) if len(data) == 0 { return lbl + dimDot.Render(strings.Repeat("·", gw)) } d := data if len(d) > gw { d = d[len(d)-gw:] } var sb strings.Builder sb.WriteString(lbl) pad := gw - len(d) if pad > 0 { sb.WriteString(dimDot.Render(strings.Repeat("·", pad))) } for _, v := range d { idx := int(v / 100 * float64(len(sparkChars)-1)) idx = clamp(idx, 0, len(sparkChars)-1) sb.WriteString(style.Render(string(sparkChars[idx]))) } return sb.String() } func tempText(t *float64) string { if t == nil || *t <= 0 { return "N/A" } return fmt.Sprintf("%.1f°C", *t) } func fmtBytes(b uint64) string { if b == 0 { return "0 B" } units := []string{"B", "KB", "MB", "GB", "TB"} f := float64(b) for _, u := range units { if f < 1024 { return fmt.Sprintf("%.1f %s", f, u) } f /= 1024 } return fmt.Sprintf("%.1f PB", f) } func clamp(v, lo, hi int) int { if v < lo { return lo } if v > hi { return hi } return v } func clampF(v, lo, hi float64) float64 { if v < lo { return lo } if v > hi { return hi } return v } // ═══════════════════════════════════════════════ // 数据采集 goroutine // ═══════════════════════════════════════════════ func runCollector(done <-chan struct{}, cfg ClientConfig, dataCh chan<- MachineData, statCh chan<- bool) { // 预热 cpu.Percent(500*time.Millisecond, true) interval := time.Duration(cfg.Interval) * time.Second if interval < time.Second { interval = 3 * time.Second } reconnect := time.Duration(cfg.ReconnectDelay) * time.Second if reconnect < time.Second { reconnect = 3 * time.Second } for { select { case <-done: return default: } wsURL := "ws://" + cfg.Server + "/ws/agent" err := collectLoop(done, wsURL, cfg.MachineName, interval, dataCh, statCh) if err != nil { select { case statCh <- false: default: } } // 断线重连等待 select { case <-done: return case <-time.After(reconnect): } } } func collectLoop(done <-chan struct{}, wsURL, name string, interval time.Duration, dataCh chan<- MachineData, statCh chan<- bool) error { conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { return err } defer conn.Close() select { case statCh <- true: default: } for { select { case <-done: return nil default: } // 采集 CPU 使用率 perCore, _ := cpu.Percent(interval, true) data := collectAll(name, perCore) if err := conn.WriteJSON(data); err != nil { return err } select { case dataCh <- data: default: // 满了就丢弃 } } } func collectAll(name string, perCore []float64) MachineData { return MachineData{ MachineName: name, Motherboard: collectMotherboard(), CPU: collectCPU(perCore), Memory: collectMemory(), GPUs: collectGPUs(), Timestamp: time.Now(), } } func collectCPU(perCore []float64) CPUInfo { pC, _ := cpu.Counts(false) lC, _ := cpu.Counts(true) var total float64 for _, p := range perCore { total += p } if len(perCore) > 0 { total /= float64(len(perCore)) } modelName := "Unknown CPU" freq := 0.0 infos, err := cpu.Info() if err == nil && len(infos) > 0 { modelName = strings.TrimSpace(infos[0].ModelName) freq = infos[0].Mhz } return CPUInfo{ Model: modelName, CoresPhysical: pC, CoresLogical: lC, FreqMHz: freq, Temperature: getCPUTemp(), UsageTotal: math.Round(total*10) / 10, UsagePerCore: perCore, } } func getCPUTemp() *float64 { if runtime.GOOS != "windows" { return nil } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", "(Get-CimInstance MSAcpi_ThermalZoneTemperature -Namespace 'root/wmi' -EA SilentlyContinue | Select -First 1).CurrentTemperature", ).Output() if err != nil { return nil } raw := strings.TrimSpace(string(out)) val, err := strconv.ParseFloat(raw, 64) if err != nil || val < 1000 { return nil } c := (val - 2732) / 10.0 if c < 0 || c > 150 { return nil } return &c } func collectMemory() MemInfo { vm, err := mem.VirtualMemory() if err != nil { return MemInfo{} } return MemInfo{ TotalBytes: vm.Total, UsedBytes: vm.Used, Percent: math.Round(vm.UsedPercent*10) / 10, } } func collectMotherboard() BoardInfo { if runtime.GOOS != "windows" { return BoardInfo{} } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "wmic", "baseboard", "get", "manufacturer,product", "/format:list").Output() if err != nil { return BoardInfo{} } var b BoardInfo for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Manufacturer=") { b.Manufacturer = strings.TrimPrefix(line, "Manufacturer=") } else if strings.HasPrefix(line, "Product=") { b.Product = strings.TrimPrefix(line, "Product=") } } return b } func collectGPUs() []GPUInfo { if runtime.GOOS == "windows" { gpus := tryNvidiaSMI() if len(gpus) > 0 { return gpus } return tryWMIGPU() } return tryNvidiaSMI() } func tryNvidiaSMI() []GPUInfo { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,name,memory.total,memory.used,memory.free,temperature.gpu,utilization.gpu,clocks.current.graphics,clocks.current.memory,power.draw,fan.speed", "--format=csv,noheader,nounits", ).Output() if err != nil { return nil } var gpus []GPUInfo for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { parts := strings.Split(line, ",") if len(parts) < 11 { continue } for i := range parts { parts[i] = strings.TrimSpace(parts[i]) } fan := parseFloat(parts[10]) if parts[10] == "[N/A]" || parts[10] == "N/A" || strings.Contains(parts[10], "[") { fan = 0 } gpus = append(gpus, GPUInfo{ Name: parts[1], VRAMTotalMB: parseFloat(parts[2]), VRAMUsedMB: parseFloat(parts[3]), Temperature: parseFloat(parts[5]), LoadPercent: parseFloat(parts[6]), CoreClock: parseFloat(parts[7]), MemClock: parseFloat(parts[8]), Power: parseFloat(parts[9]), FanSpeed: fan, }) } return gpus } func tryWMIGPU() []GPUInfo { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | Select Name,AdapterRAM | ConvertTo-Json -Compress", ).Output() if err != nil { return nil } type wg struct { Name string `json:"Name"` AdapterRAM int64 `json:"AdapterRAM"` } s := strings.TrimSpace(string(out)) if !strings.HasPrefix(s, "[") { s = "[" + s + "]" } var items []wg if json.Unmarshal([]byte(s), &items) != nil { return nil } var gpus []GPUInfo for _, item := range items { var ram float64 if item.AdapterRAM > 0 { ram = float64(item.AdapterRAM) / (1024 * 1024) } gpus = append(gpus, GPUInfo{Name: item.Name, VRAMTotalMB: ram}) } return gpus } func parseFloat(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) return v } // ═══════════════════════════════════════════════ // main // ═══════════════════════════════════════════════ func main() { runtime.GOMAXPROCS(4) showVersion := flag.Bool("version", false, "显示版本信息") flag.Parse() if *showVersion { fmt.Printf("Hardware Monitor Client %s\n", Version) fmt.Printf("Build: %s / %s\n", BuildTime, runtime.GOOS+"/"+runtime.GOARCH) return } hostname, _ := os.Hostname() cfg, loaded := loadConfig() if loaded { fmt.Printf(" [✓] 配置文件: %s\n", configPath()) fmt.Printf(" [✓] 设备名称: %s\n", cfg.MachineName) fmt.Printf(" [✓] 服务端 : %s\n", cfg.Server) fmt.Printf(" [✓] 采集间隔: %ds\n", cfg.Interval) fmt.Println() } m := newModel(hostname, cfg, loaded) p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "错误: %v\n", err) os.Exit(1) } }