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