1188 lines
30 KiB
Go
1188 lines
30 KiB
Go
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"`
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// 样式 (全部使用 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",
|
||
"--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) < 10 {
|
||
continue
|
||
}
|
||
for i := range parts {
|
||
parts[i] = strings.TrimSpace(parts[i])
|
||
}
|
||
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]),
|
||
})
|
||
}
|
||
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)
|
||
}
|
||
}
|