1398 lines
35 KiB
Go
1398 lines
35 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"`
|
||
PowerLimit float64 `json:"power_limit"`
|
||
FanSpeed float64 `json:"fan_speed"`
|
||
PState string `json:"pstate"`
|
||
Persistence string `json:"persistence_mode"`
|
||
ComputeMode string `json:"compute_mode"`
|
||
DriverVersion string `json:"driver_version"`
|
||
CudaVersion string `json:"cuda_version"`
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// 样式 (全部使用 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
|
||
}
|
||
innerW := tw - 4
|
||
|
||
// ── 顶栏 ──
|
||
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))
|
||
|
||
// ── 内容:全部垂直排列 ──
|
||
cpuBox := m.viewCPUBox(innerW)
|
||
memBox := m.viewMemBox(innerW)
|
||
histBox := m.viewHistBox(innerW)
|
||
gpuBox := m.viewGPUBox(innerW)
|
||
|
||
full := lipgloss.JoinVertical(lipgloss.Left,
|
||
header, sep, "",
|
||
cpuBox, "",
|
||
memBox, "",
|
||
histBox, "",
|
||
gpuBox, "",
|
||
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 - 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") + 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: // 满了就丢弃
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
return tryWMIGPU()
|
||
case "linux":
|
||
gpus := tryNvidiaSMI()
|
||
if len(gpus) > 0 {
|
||
return gpus
|
||
}
|
||
return tryLinuxGPU()
|
||
case "darwin":
|
||
gpus := tryNvidiaSMI()
|
||
if len(gpus) > 0 {
|
||
return gpus
|
||
}
|
||
return tryMacGPU()
|
||
}
|
||
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,power.limit,fan.speed,pstate,persistence_mode,compute_mode,driver_version,cuda_version",
|
||
"--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) < 9 {
|
||
continue
|
||
}
|
||
for i := range parts {
|
||
parts[i] = strings.TrimSpace(parts[i])
|
||
parts[i] = strings.Trim(parts[i], "[]")
|
||
}
|
||
|
||
g := 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]),
|
||
}
|
||
if len(parts) > 9 {
|
||
g.Power = parseFloatSafe(parts[9])
|
||
}
|
||
if len(parts) > 10 {
|
||
g.PowerLimit = parseFloatSafe(parts[10])
|
||
}
|
||
if len(parts) > 11 {
|
||
g.FanSpeed = parseFloatSafe(parts[11])
|
||
}
|
||
if len(parts) > 12 {
|
||
g.PState = cleanStr(parts[12])
|
||
}
|
||
if len(parts) > 13 {
|
||
g.Persistence = cleanStr(parts[13])
|
||
}
|
||
if len(parts) > 14 {
|
||
g.ComputeMode = cleanStr(parts[14])
|
||
}
|
||
if len(parts) > 15 {
|
||
g.DriverVersion = cleanStr(parts[15])
|
||
}
|
||
if len(parts) > 16 {
|
||
g.CudaVersion = cleanStr(parts[16])
|
||
}
|
||
gpus = append(gpus, g)
|
||
}
|
||
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)
|
||
}
|
||
}
|