feat: nvidia-smi full field parsing, GPU TUI compact table, GPU summary at top, power fix

This commit is contained in:
ngfchl
2026-06-20 14:04:14 +08:00
parent 6ebad2a065
commit 7ff0973a5b
2 changed files with 330 additions and 122 deletions
+322 -121
View File
@@ -134,15 +134,21 @@ type MemInfo struct {
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"`
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"`
}
// ═══════════════════════════════════════════════
@@ -543,6 +549,10 @@ func (m model) viewMonitor() string {
if tw < 60 {
tw = 60
}
th := m.h
if th < 20 {
th = 20
}
// ── 顶栏 ──
wsDot := "○"
@@ -602,7 +612,7 @@ func (m model) viewMonitor() string {
full := lipgloss.JoinVertical(lipgloss.Left,
header, sep, "", mainContent, "", sep, footer,
)
return full
return lipgloss.Place(tw, th, lipgloss.Left, lipgloss.Top, full)
}
// ── CPU 盒子 ──
@@ -714,7 +724,7 @@ func (m model) viewMemBox(innerW int) string {
// ── GPU 盒子 ──
func (m model) viewGPUBox(innerW int) string {
titleLine := stTitle.Render("GPU")
titleLine := stTitle.Render("GPU") + stDim.Render(fmt.Sprintf(" (%d张)", len(m.gpus)))
if len(m.gpus) == 0 {
body := lipgloss.JoinVertical(lipgloss.Left,
@@ -723,68 +733,88 @@ func (m model) viewGPUBox(innerW int) string {
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, "")
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 {
if i > 0 {
blocks = append(blocks, stDim.Render(" "+strings.Repeat("·", innerW-4)))
tmpC := tempStyle(&g.Temperature)
loadC := pctStyle(g.LoadPercent)
vramStr := "—"
if g.VRAMTotalMB > 0 {
vramStr = fmt.Sprintf("%.0f/%.0fM", g.VRAMUsedMB, g.VRAMTotalMB)
}
blocks = append(blocks, m.viewSingleGPU(g, innerW))
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) viewSingleGPU(g GPUInfo, w int) string {
// 名称
nm := g.Name
if len(nm) > w-2 && w > 5 {
nm = nm[:w-5] + "…"
func (m model) avgTemp() float64 {
if len(m.gpus) == 0 {
return 0
}
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,
)
total := 0.0
for _, g := range m.gpus {
total += g.Temperature
}
return lipgloss.JoinVertical(lipgloss.Left,
nameLine, statsLine, clockLine, vramLine,
)
return total / float64(len(m.gpus))
}
// ── History 盒子 ──
@@ -1014,27 +1044,72 @@ func collectCPU(perCore []float64) CPUInfo {
}
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
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
}
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
return nil
}
func collectMemory() MemInfo {
@@ -1050,34 +1125,76 @@ func collectMemory() MemInfo {
}
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=")
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 b
return BoardInfo{}
}
func collectGPUs() []GPUInfo {
if runtime.GOOS == "windows" {
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()
}
@@ -1086,7 +1203,7 @@ 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",
"--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 {
@@ -1100,38 +1217,52 @@ func tryNvidiaSMI() []GPUInfo {
}
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
parts[i] = strings.Trim(parts[i], "[]")
}
fanStr := strings.TrimSpace(parts[10])
fanStr = strings.Trim(fanStr, "[]")
var fan float64
if fanStr == "N/A" || fanStr == "" {
fan = 0
} else {
fan = parseFloat(fanStr)
}
powerStr := strings.TrimSpace(parts[9])
powerStr = strings.Trim(powerStr, "[]")
var power float64
if powerStr == "N/A" || powerStr == "" {
power = 0
} else {
power = parseFloat(powerStr)
}
fan := parseFloatSafe(parts[10])
power := parseFloatSafe(parts[9])
powerLimit := parseFloatSafe(parts[10])
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: power,
FanSpeed: fan,
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: power,
PowerLimit: powerLimit,
FanSpeed: fan,
PState: cleanStr(parts[12]),
Persistence: cleanStr(parts[13]),
ComputeMode: cleanStr(parts[14]),
DriverVersion: cleanStr(parts[15]),
CudaVersion: cleanStr(parts[16]),
})
}
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()
@@ -1164,6 +1295,76 @@ func tryWMIGPU() []GPUInfo {
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
+8 -1
View File
@@ -344,13 +344,20 @@ Vue.component('gpu-section',{
<div class="gpu-pop-grid">
<div class="gd-item"><span class="gd-l">负载</span><span class="gd-v" :style="{color:pColor(gg.load_percent||0)}">{{ (gg.load_percent||0).toFixed(1) }}%</span></div>
<div class="gd-item"><span class="gd-l">温度</span><span class="gd-v" :style="{color:tmpColor(gg)}">{{ tmpStr(gg) }}</span></div>
<div class="gd-item"><span class="gd-l">功耗</span><span class="gd-v">{{ Math.round(gg.power||0) }} W</span></div>
<div class="gd-item"><span class="gd-l">功耗</span><span class="gd-v">{{ Math.round(gg.power||0) }}W / {{ Math.round(gg.power_limit||0) }}W</span></div>
<div class="gd-item"><span class="gd-l">核心频率</span><span class="gd-v">{{ Math.round(gg.core_clock_mhz||0) }} MHz</span></div>
<div class="gd-item"><span class="gd-l">显存频率</span><span class="gd-v">{{ Math.round(gg.mem_clock_mhz||0) }} MHz</span></div>
<div class="gd-item"><span class="gd-l">风扇</span><span class="gd-v">{{ fanStr(gg) }}</span></div>
<div class="gd-item"><span class="gd-l">性能状态</span><span class="gd-v">{{ gg.pstate||'N/A' }}</span></div>
<div class="gd-item"><span class="gd-l">持久模式</span><span class="gd-v">{{ gg.persistence_mode||'N/A' }}</span></div>
<div class="gd-item"><span class="gd-l">计算模式</span><span class="gd-v">{{ gg.compute_mode||'N/A' }}</span></div>
</div>
<div v-if="vramTotal(gg)>0" style="margin-top:2px"><div class="info-row" style="margin-bottom:1px"><span class="label">显存</span><span class="value">{{ vramUsed(gg) }} / {{ vramTotal(gg) }} GB</span></div>
<a-progress :percent="Math.round(vramPct(gg))" :stroke-color="pColor(vramPct(gg))" :stroke-width="4" size="small" :format="fmtPct" /></div>
<div v-if="gg.driver_version" style="margin-top:2px;font-size:8px;color:var(--dim);display:flex;gap:8px">
<span>驱动: {{ gg.driver_version }}</span>
<span v-if="gg.cuda_version">CUDA: {{ gg.cuda_version }}</span>
</div>
</div>
</div></template>
<div @click.stop="toggle(i)">