From 7ff0973a5b22f0848765ef8da234e80d8b08fe63 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 20 Jun 2026 14:04:14 +0800 Subject: [PATCH] feat: nvidia-smi full field parsing, GPU TUI compact table, GPU summary at top, power fix --- cmd/client/main.go | 443 +++++++++++++++++++++++--------- cmd/server/templates/index.html | 9 +- 2 files changed, 330 insertions(+), 122 deletions(-) diff --git a/cmd/client/main.go b/cmd/client/main.go index 27b690b..85011bc 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -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, "CPU die temperature") + if idx < 0 { + return nil + } + s = s[idx:] + idx = strings.Index(s, "") + if idx < 0 { + return nil + } + s = s[idx+6:] + end := strings.Index(s, "") + 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 diff --git a/cmd/server/templates/index.html b/cmd/server/templates/index.html index 8d91724..5345898 100644 --- a/cmd/server/templates/index.html +++ b/cmd/server/templates/index.html @@ -344,13 +344,20 @@ Vue.component('gpu-section',{
负载{{ (gg.load_percent||0).toFixed(1) }}%
温度{{ tmpStr(gg) }}
-
功耗{{ Math.round(gg.power||0) }} W
+
功耗{{ Math.round(gg.power||0) }}W / {{ Math.round(gg.power_limit||0) }}W
核心频率{{ Math.round(gg.core_clock_mhz||0) }} MHz
显存频率{{ Math.round(gg.mem_clock_mhz||0) }} MHz
风扇{{ fanStr(gg) }}
+
性能状态{{ gg.pstate||'N/A' }}
+
持久模式{{ gg.persistence_mode||'N/A' }}
+
计算模式{{ gg.compute_mode||'N/A' }}
显存{{ vramUsed(gg) }} / {{ vramTotal(gg) }} GB
+
+ 驱动: {{ gg.driver_version }} + CUDA: {{ gg.cuda_version }} +