Files
BitMonitor/cmd/client/main.go
T

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