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 }}
+