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"` PowerLimit float64 `json:"power_limit"` PowerMax float64 `json:"power_max"` FanSpeed float64 `json:"fan_speed"` PState string `json:"pstate"` ComputeMode string `json:"compute_mode"` DriverVersion string `json:"driver_version"` UtilMemory float64 `json:"util_memory"` UtilEncoder float64 `json:"util_encoder"` UtilDecoder float64 `json:"util_decoder"` ClocksMaxGfx float64 `json:"clocks_max_graphics"` ClocksMaxMem float64 `json:"clocks_max_memory"` } // ═══════════════════════════════════════════════ // 样式 (全部使用 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 } gap := 2 halfW := (tw - gap) / 2 // ── 顶栏 ── 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)) // ── 第一行: CPU + Memory ── cpuBox := m.viewCPUBox(halfW - 4) memBox := m.viewMemBox(halfW - 4) row1 := lipgloss.JoinHorizontal(lipgloss.Top, cpuBox, strings.Repeat(" ", gap), memBox, ) // ── 第二行: GPU + History ── gpuBox := m.viewGPUBox(halfW - 4) histBox := m.viewHistBox(halfW - 4) row2 := lipgloss.JoinHorizontal(lipgloss.Top, gpuBox, strings.Repeat(" ", gap), histBox, ) full := lipgloss.JoinVertical(lipgloss.Left, header, sep, "", row1, "", row2, "", sep, stDim.Render(" q 退出 · r 重新配置 · 连接: "+m.cfg.Server), ) lines := strings.Split(full, "\n") if len(lines) > m.h { lines = lines[:m.h] } return strings.Join(lines, "\n") } // ── CPU 盒子 ── func (m model) viewCPUBox(innerW int) string { c := m.cpu pct := c.UsageTotal modelName := c.Model maxNameW := innerW - 8 if len(modelName) > maxNameW && maxNameW > 3 { modelName = modelName[:maxNameW-1] + "…" } // 标题 + 核心/频率/温度 一行 coresStr := fmt.Sprintf("%dC/%dT", c.CoresPhysical, c.CoresLogical) freqStr := fmt.Sprintf("%.0fMHz", c.FreqMHz) tempStr := tempText(c.Temperature) titleLine := lipgloss.JoinHorizontal(lipgloss.Left, stTitle.Render("CPU"), " ", stFg.Render(modelName), " ", stLabel.Render(coresStr), " ", stVal.Render(freqStr), " ", tempStyle(c.Temperature).Render(tempStr), ) // 进度条 + 百分比 一行 barW := innerW - 8 if barW < 10 { barW = 10 } bar := renderBar(pct, barW) pctStr := pctStyle(pct).Bold(true).Render(fmt.Sprintf("%.1f%%", pct)) barLine := lipgloss.JoinHorizontal(lipgloss.Center, bar, " ", pctStr) // 核心热力图 coreLine := m.viewCoreHeatmap(innerW) body := lipgloss.JoinVertical(lipgloss.Left, titleLine, barLine, 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 usedGB := fmtBytes(m.mem.UsedBytes) totalGB := fmtBytes(m.mem.TotalBytes) // 标题 + 详情 一行 titleLine := lipgloss.JoinHorizontal(lipgloss.Left, stTitle.Render("MEMORY"), " ", stLabel.Render("已用 "), stVal.Render(usedGB), " / ", stVal.Render(totalGB), " ", 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) body := lipgloss.JoinVertical(lipgloss.Left, titleLine, barLine, ) return stBox.Width(innerW + 4).Render(body) } // ── GPU 盒子 ── func (m model) viewGPUBox(innerW int) string { titleLine := stTitle.Render("GPU") + stDim.Render(fmt.Sprintf(" (%d张)", len(m.gpus))) if len(m.gpus) == 0 { body := lipgloss.JoinVertical(lipgloss.Left, titleLine, "", stDim.Render(" 未检测到 GPU"), ) return stBox.Width(innerW + 4).Render(body) } // 总功耗汇总 totalP := 0.0 totalFan := 0.0 fanCnt := 0 for _, g := range m.gpus { totalP += g.Power if g.FanSpeed > 0 { totalFan += g.FanSpeed fanCnt++ } } avgFan := 0.0 if fanCnt > 0 { avgFan = totalFan / float64(fanCnt) } summary := stDim.Render(" ") + stVal.Render(fmt.Sprintf("总功耗: %.0fW", totalP)) + stDim.Render(" │ ") + stVal.Render(fmt.Sprintf("均温: %.0f°C", m.avgTemp())) + stDim.Render(" │ ") + stVal.Render(fmt.Sprintf("风扇: %.0f%%", avgFan)) var blocks []string blocks = append(blocks, titleLine, summary, "") // 表头 hdr := lipgloss.JoinHorizontal(lipgloss.Left, stDim.Render(fmt.Sprintf(" %-4s", "#")), stDim.Render(fmt.Sprintf("%-6s", "Temp")), stDim.Render(fmt.Sprintf("%-6s", "Load")), stDim.Render(fmt.Sprintf("%-8s", "Power")), stDim.Render(fmt.Sprintf("%-10s", "Clock")), stDim.Render(fmt.Sprintf("%-10s", "MemClk")), stDim.Render(fmt.Sprintf("%-10s", "VRAM")), stDim.Render(fmt.Sprintf("%-5s", "Fan")), ) blocks = append(blocks, hdr, stDim.Render(" "+strings.Repeat("─", innerW-4))) for i, g := range m.gpus { tmpC := tempStyle(&g.Temperature) loadC := pctStyle(g.LoadPercent) vramStr := "—" if g.VRAMTotalMB > 0 { vramStr = fmt.Sprintf("%.0f/%.0fM", g.VRAMUsedMB, g.VRAMTotalMB) } fanStr := "—" if g.FanSpeed > 0 { fanStr = fmt.Sprintf("%.0f%%", g.FanSpeed) } line := lipgloss.JoinHorizontal(lipgloss.Left, stVal.Render(fmt.Sprintf(" %-4d", i)), tmpC.Render(fmt.Sprintf("%-6.0f°C", g.Temperature)), loadC.Render(fmt.Sprintf("%-6.0f%%", g.LoadPercent)), stVal.Render(fmt.Sprintf("%-8.0fW", g.Power)), stVal.Render(fmt.Sprintf("%-10.0f", g.CoreClock)), stVal.Render(fmt.Sprintf("%-10.0f", g.MemClock)), stVal.Render(fmt.Sprintf("%-10s", vramStr)), stVal.Render(fmt.Sprintf("%-5s", fanStr)), ) blocks = append(blocks, line) } // 总功耗 totalP = 0.0 for _, g := range m.gpus { totalP += g.Power } blocks = append(blocks, stDim.Render(" ")+stVal.Render(fmt.Sprintf("总功耗: %.0fW", totalP)), "") body := lipgloss.JoinVertical(lipgloss.Left, blocks...) return stBox.Width(innerW + 4).Render(body) } func (m model) avgTemp() float64 { if len(m.gpus) == 0 { return 0 } total := 0.0 for _, g := range m.gpus { total += g.Temperature } return total / float64(len(m.gpus)) } // ── 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: // 满了就丢弃 } // 每 10 轮触发一次 GC,减少内存占用 runtime.GC() } } 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 { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() switch runtime.GOOS { case "windows": 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 case "linux": out, err := exec.CommandContext(ctx, "cat", "/sys/class/thermal/thermal_zone0/temp").Output() if err != nil { return nil } raw := strings.TrimSpace(string(out)) val, err := strconv.ParseFloat(raw, 64) if err != nil { return nil } c := val / 1000.0 if c < 0 || c > 150 { return nil } return &c case "darwin": out, err := exec.CommandContext(ctx, "sudo", "powermetrics", "--samplers", "smc", "-i", "1", "-n", "1", "--format", "plist").Output() if err != nil { return nil } s := string(out) idx := strings.Index(s, "CPU die temperature") if idx < 0 { return nil } s = s[idx:] idx = strings.Index(s, "") if idx < 0 { return nil } s = s[idx+6:] end := strings.Index(s, "") if end < 0 { return nil } val, err := strconv.ParseFloat(strings.TrimSpace(s[:end]), 64) if err != nil { return nil } if val < 0 || val > 150 { return nil } return &val } return nil } 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 { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() switch runtime.GOOS { case "windows": 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 case "linux": man, _ := os.ReadFile("/sys/class/dmi/id/board_vendor") prod, _ := os.ReadFile("/sys/class/dmi/id/board_name") return BoardInfo{ Manufacturer: strings.TrimSpace(string(man)), Product: strings.TrimSpace(string(prod)), } case "darwin": out, err := exec.CommandContext(ctx, "system_profiler", "SPHardwareDataType").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, "Model Name:") { b.Product = strings.TrimSpace(strings.TrimPrefix(line, "Model Name:")) } else if strings.HasPrefix(line, "Model Identifier:") { if b.Manufacturer == "" { b.Manufacturer = "Apple" } } } if b.Manufacturer == "" { b.Manufacturer = "Apple" } return b } return BoardInfo{} } func collectGPUs() []GPUInfo { switch runtime.GOOS { case "windows": gpus := tryNvidiaSMI() if len(gpus) > 0 { return gpus } wmi := tryWMIGPU() return filterPCIeGPUs(wmi) case "linux": gpus := tryNvidiaSMI() if len(gpus) > 0 { return gpus } lspci := tryLinuxGPU() return filterPCIeGPUs(lspci) case "darwin": gpus := tryNvidiaSMI() mac := tryMacGPU() return filterPCIeGPUs(append(gpus, mac...)) } return tryNvidiaSMI() } func filterPCIeGPUs(gpus []GPUInfo) []GPUInfo { var result []GPUInfo for _, g := range gpus { if !isVirtualGPU(g.Name) { result = append(result, g) } } return result } func isVirtualGPU(name string) bool { name = strings.ToLower(name) blacklist := []string{ "microsoft", "basic render", "vmware", "virtual", "hyper-v", "parallels", "virtualbox", "intel", "remote", "rdp", "citrix", "teamviewer", "anydesk", "parsec", "splashtop", "oray", "sunlogin", "todesk", "amdgpu-pro", } for _, v := range blacklist { if strings.Contains(name, v) { return true } } return false } 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,uuid,pci.bus_id,driver_version,vbios_version,"+ "temperature.gpu,temperature.memory,fan.speed,"+ "power.draw,power.limit,power.default_limit,power.min_limit,power.max_limit,"+ "clocks.current.graphics,clocks.current.sm,clocks.current.memory,"+ "clocks.max.graphics,clocks.max.memory,"+ "utilization.gpu,utilization.memory,utilization.encoder,utilization.decoder,"+ "memory.total,memory.used,memory.free,"+ "ecc.errors.corrected.volatile.total,ecc.errors.uncorrected.volatile.total,"+ "compute_mode,pstate", "--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) < 24 { continue } for i := range parts { parts[i] = strings.TrimSpace(parts[i]) parts[i] = strings.Trim(parts[i], "[]") } // 0:index 1:name 2:uuid 3:bus_id 4:driver 5:vbios // 6:temp_gpu 7:temp_mem 8:fan 9:power 10:power_limit // 11:power_default 12:power_min 13:power_max // 14:clk_gfx 15:clk_sm 16:clk_mem 17:clk_max_gfx 18:clk_max_mem // 19:util_gpu 20:util_mem 21:util_enc 22:util_dec // 23:vram_total 24:vram_used 25:vram_free // 26:ecc_corr 27:ecc_uncorr 28:compute_mode 29:pstate gpus = append(gpus, GPUInfo{ Name: cleanStr(parts[1]), DriverVersion: cleanStr(parts[4]), Temperature: parseFloat(parts[6]), FanSpeed: parseFloat(parts[8]), Power: parseFloat(parts[9]), PowerLimit: parseFloat(parts[10]), PowerMax: parseFloat(parts[13]), CoreClock: parseFloat(parts[14]), MemClock: parseFloat(parts[16]), ClocksMaxGfx: parseFloat(parts[17]), ClocksMaxMem: parseFloat(parts[18]), LoadPercent: parseFloat(parts[19]), UtilMemory: parseFloat(parts[20]), UtilEncoder: parseFloat(parts[21]), UtilDecoder: parseFloat(parts[22]), VRAMTotalMB: parseFloat(parts[23]), VRAMUsedMB: parseFloat(parts[24]), ComputeMode: cleanStr(parts[28]), PState: cleanStr(parts[29]), }) } return gpus } func parseFloatSafe(s string) float64 { s = strings.Trim(s, "[] ") s = strings.TrimSuffix(s, "W") s = strings.TrimSpace(s) if s == "N/A" || s == "" { return 0 } return parseFloat(s) } func cleanStr(s string) string { s = strings.Trim(s, "[] ") if s == "N/A" || s == "[N/A]" { return "" } return s } 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 tryLinuxGPU() []GPUInfo { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "lspci", "-vnn").Output() if err != nil { return nil } var gpus []GPUInfo lines := strings.Split(string(out), "\n") for i, line := range lines { if strings.Contains(line, "VGA") || strings.Contains(line, "3D") || strings.Contains(line, "Display") { name := line if idx := strings.Index(name, ": "); idx >= 0 { name = name[idx+2:] } if idx := strings.Index(name, " ["); idx >= 0 { name = name[:idx] } name = strings.TrimSpace(name) var vram float64 for j := i + 1; j < len(lines) && j < i+10; j++ { if strings.Contains(lines[j], "prefetchable") && strings.Contains(lines[j], "Memory") { if idx := strings.Index(lines[j], "size="); idx >= 0 { sz := lines[j][idx+5:] if end := strings.Index(sz, "M"); end > 0 { vram, _ = strconv.ParseFloat(sz[:end], 64) } else if end := strings.Index(sz, "G"); end > 0 { v, _ := strconv.ParseFloat(sz[:end], 64) vram = v * 1024 } } } } gpus = append(gpus, GPUInfo{Name: name, VRAMTotalMB: vram}) } } return gpus } func tryMacGPU() []GPUInfo { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "system_profiler", "SPDisplaysDataType", "-json").Output() if err != nil { return nil } var data struct { SPDisplaysDataType []struct { sppci_model string `json:"_name"` sppci_memory string `json:"sppci_memory"` SPDisplaysVendorID string `json:"spdisplays_vendor-id"` } `json:"SPDisplaysDataType"` } if json.Unmarshal(out, &data) != nil { return nil } var gpus []GPUInfo for _, d := range data.SPDisplaysDataType { g := GPUInfo{Name: d.sppci_model} if vram, err := strconv.ParseFloat(d.sppci_memory, 64); err == nil { g.VRAMTotalMB = vram / (1024 * 1024) if g.VRAMTotalMB > 1024*10 { g.VRAMTotalMB = vram / (1024 * 1024) } } gpus = append(gpus, g) } 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) } }