Files

1434 lines
37 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "<key>CPU die temperature</key>")
if idx < 0 {
return nil
}
s = s[idx:]
idx = strings.Index(s, "<real>")
if idx < 0 {
return nil
}
s = s[idx+6:]
end := strings.Index(s, "</real>")
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)
}
}