Compare commits

..

16 Commits

Author SHA1 Message Date
ngfchl 095f2845f6 refactor: remove countdown/spin from header, simplify status bar 2026-06-21 13:32:28 +08:00
ngfchl 2f6bea41d5 style: countdown spinner with text overlay, configurable refresh interval 2026-06-21 10:10:26 +08:00
ngfchl 5ef7937d3c fix: client only collect PCIe GPUs, filter virtual/integrated GPUs, add GC for memory optimization 2026-06-21 10:03:19 +08:00
ngfchl b412b8f9d1 fix: add Oray, Sunlogin, ToDesk to GPU blacklist 2026-06-21 09:49:16 +08:00
ngfchl 9bdc92252e style: font size increase, GPU core clock display, mining time grid layout, CPU model smaller 2026-06-21 00:50:10 +08:00
ngfchl 62143a5a32 style: status bar margin, donut text enlargement, mining contrast, loading icon order, mobile dashboard hide 2026-06-20 23:44:04 +08:00
ngfchl 2c103af9d8 feat: mining section rework, GPU filter, chart as full-width row, progress bars separate line, contrast fix 2026-06-20 16:46:21 +08:00
ngfchl d39713a196 feat: comprehensive 30-field nvidia-smi query, TUI 2-row layout, card width 600px 2026-06-20 15:28:47 +08:00
ngfchl 8f1325800c fix: simplify nvidia-smi query to 15 fields, remove persistence_mode/compute_mode causing field offset 2026-06-20 15:12:47 +08:00
ngfchl 2377530fab fix: nvidia-smi robust parsing with fallback defaults, card width adjustment 2026-06-20 14:59:36 +08:00
ngfchl 6605adaed5 fix: nvidia-smi field index mapping, TUI vertical layout to prevent scroll jump 2026-06-20 14:53:59 +08:00
ngfchl 7ff0973a5b feat: nvidia-smi full field parsing, GPU TUI compact table, GPU summary at top, power fix 2026-06-20 14:04:14 +08:00
ngfchl 6ebad2a065 feat: GPU power display, font enlargement, countdown timer, dim color fix, miner_uid login 2026-06-20 10:29:27 +08:00
ngfchl 7217974395 update. 优化服务端以及 HTML 显示效果 2026-06-20 00:13:13 +08:00
ngfchl 65310c07e9 fix: parse nvidia-smi fan.speed bracket format [30]/[N/A] 2026-06-19 20:25:21 +08:00
ngfchl 33d765b124 feat: GPU blocks display with click-to-expand, fix fan speed parsing, compress chart 2026-06-19 19:47:43 +08:00
5 changed files with 1820 additions and 1338 deletions
+1 -1
View File
@@ -8,6 +8,6 @@
# 删除此文件可重新配置
machine_name: my-pc
server: 192.168.1.100:8080
server: 127.0.0.1:8080
interval: 3
reconnect_delay: 3
+419 -175
View File
@@ -134,15 +134,25 @@ 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"`
PowerMax float64 `json:"power_max"`
FanSpeed float64 `json:"fan_speed"`
PState string `json:"pstate"`
ComputeMode string `json:"compute_mode"`
DriverVersion string `json:"driver_version"`
UtilMemory float64 `json:"util_memory"`
UtilEncoder float64 `json:"util_encoder"`
UtilDecoder float64 `json:"util_decoder"`
ClocksMaxGfx float64 `json:"clocks_max_graphics"`
ClocksMaxMem float64 `json:"clocks_max_memory"`
}
// ═══════════════════════════════════════════════
@@ -543,6 +553,8 @@ func (m model) viewMonitor() string {
if tw < 60 {
tw = 60
}
gap := 2
halfW := (tw - gap) / 2
// ── 顶栏 ──
wsDot := "○"
@@ -562,47 +574,33 @@ func (m model) viewMonitor() string {
)
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 + Memory ──
cpuBox := m.viewCPUBox(halfW - 4)
memBox := m.viewMemBox(halfW - 4)
row1 := lipgloss.JoinHorizontal(lipgloss.Top,
cpuBox, strings.Repeat(" ", gap), memBox,
)
// ── 左列: 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)
// ── 第二行: GPU + History ──
gpuBox := m.viewGPUBox(halfW - 4)
histBox := m.viewHistBox(halfW - 4)
row2 := lipgloss.JoinHorizontal(lipgloss.Top,
gpuBox, strings.Repeat(" ", gap), histBox,
)
full := lipgloss.JoinVertical(lipgloss.Left,
header, sep, "", mainContent, "", sep, footer,
header, sep, "",
row1, "",
row2, "",
sep,
stDim.Render(" q 退出 · r 重新配置 · 连接: "+m.cfg.Server),
)
return full
lines := strings.Split(full, "\n")
if len(lines) > m.h {
lines = lines[:m.h]
}
return strings.Join(lines, "\n")
}
// ── CPU 盒子 ──
@@ -611,40 +609,38 @@ func (m model) viewCPUBox(innerW int) string {
c := m.cpu
pct := c.UsageTotal
// 标题
modelName := c.Model
maxNameW := innerW - 10
maxNameW := innerW - 8
if len(modelName) > maxNameW && maxNameW > 3 {
modelName = modelName[:maxNameW-1] + "…"
}
titleLine := stTitle.Render("CPU") + " " + stFg.Render(modelName)
// 百分比进度条
// 标题 + 核心/频率/温度 一行
coresStr := fmt.Sprintf("%dC/%dT", c.CoresPhysical, c.CoresLogical)
freqStr := fmt.Sprintf("%.0fMHz", c.FreqMHz)
tempStr := tempText(c.Temperature)
titleLine := lipgloss.JoinHorizontal(lipgloss.Left,
stTitle.Render("CPU"),
" ", stFg.Render(modelName),
" ", stLabel.Render(coresStr),
" ", stVal.Render(freqStr),
" ", tempStyle(c.Temperature).Render(tempStr),
)
// 进度条 + 百分比 一行
barW := innerW - 8
if barW < 10 {
barW = 10
}
bar := renderBar(pct, barW)
pctStr := pctStyle(pct).Bold(true).Render(fmt.Sprintf("%5.1f%%", pct))
pctStr := pctStyle(pct).Bold(true).Render(fmt.Sprintf("%.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,
titleLine, barLine, coreLine,
)
return stBox.Width(innerW + 4).Render(body)
}
@@ -683,10 +679,16 @@ func (m model) viewCoreHeatmap(innerW int) string {
func (m model) viewMemBox(innerW int) string {
pct := m.mem.Percent
titleLine := stTitle.Render("MEMORY")
usedGB := fmtBytes(m.mem.UsedBytes)
totalGB := fmtBytes(m.mem.TotalBytes)
// 百分比
pctStr := pctStyle(pct).Bold(true).Render(fmt.Sprintf("%.1f%%", pct))
// 标题 + 详情 一行
titleLine := lipgloss.JoinHorizontal(lipgloss.Left,
stTitle.Render("MEMORY"),
" ", stLabel.Render("已用 "), stVal.Render(usedGB),
" / ", stVal.Render(totalGB),
" ", pctStyle(pct).Bold(true).Render(fmt.Sprintf("%.1f%%", pct)),
)
// 进度条
barW := innerW - 8
@@ -694,19 +696,10 @@ func (m model) viewMemBox(innerW int) string {
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),
)
barLine := lipgloss.JoinHorizontal(lipgloss.Center, bar)
body := lipgloss.JoinVertical(lipgloss.Left,
titleLine, "", pctStr, barLine, "", detailLine,
titleLine, barLine,
)
return stBox.Width(innerW + 4).Render(body)
}
@@ -714,7 +707,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 +716,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 盒子 ──
@@ -968,6 +981,9 @@ func collectLoop(done <-chan struct{}, wsURL, name string, interval time.Duratio
case dataCh <- data:
default: // 满了就丢弃
}
// 每 10 轮触发一次 GC,减少内存占用
runtime.GC()
}
}
@@ -1014,27 +1030,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,43 +1111,119 @@ 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()
wmi := tryWMIGPU()
return filterPCIeGPUs(wmi)
case "linux":
gpus := tryNvidiaSMI()
if len(gpus) > 0 {
return gpus
}
lspci := tryLinuxGPU()
return filterPCIeGPUs(lspci)
case "darwin":
gpus := tryNvidiaSMI()
mac := tryMacGPU()
return filterPCIeGPUs(append(gpus, mac...))
}
return tryNvidiaSMI()
}
func filterPCIeGPUs(gpus []GPUInfo) []GPUInfo {
var result []GPUInfo
for _, g := range gpus {
if !isVirtualGPU(g.Name) {
result = append(result, g)
}
}
return result
}
func isVirtualGPU(name string) bool {
name = strings.ToLower(name)
blacklist := []string{
"microsoft", "basic render", "vmware", "virtual", "hyper-v",
"parallels", "virtualbox", "intel", "remote", "rdp", "citrix",
"teamviewer", "anydesk", "parsec", "splashtop", "oray",
"sunlogin", "todesk", "amdgpu-pro",
}
for _, v := range blacklist {
if strings.Contains(name, v) {
return true
}
}
return false
}
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,uuid,pci.bus_id,driver_version,vbios_version,"+
"temperature.gpu,temperature.memory,fan.speed,"+
"power.draw,power.limit,power.default_limit,power.min_limit,power.max_limit,"+
"clocks.current.graphics,clocks.current.sm,clocks.current.memory,"+
"clocks.max.graphics,clocks.max.memory,"+
"utilization.gpu,utilization.memory,utilization.encoder,utilization.decoder,"+
"memory.total,memory.used,memory.free,"+
"ecc.errors.corrected.volatile.total,ecc.errors.uncorrected.volatile.total,"+
"compute_mode,pstate",
"--format=csv,noheader,nounits",
).Output()
if err != nil {
@@ -1095,27 +1232,64 @@ func tryNvidiaSMI() []GPUInfo {
var gpus []GPUInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.Split(line, ",")
if len(parts) < 10 {
if len(parts) < 24 {
continue
}
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
parts[i] = strings.Trim(parts[i], "[]")
}
// 0:index 1:name 2:uuid 3:bus_id 4:driver 5:vbios
// 6:temp_gpu 7:temp_mem 8:fan 9:power 10:power_limit
// 11:power_default 12:power_min 13:power_max
// 14:clk_gfx 15:clk_sm 16:clk_mem 17:clk_max_gfx 18:clk_max_mem
// 19:util_gpu 20:util_mem 21:util_enc 22:util_dec
// 23:vram_total 24:vram_used 25:vram_free
// 26:ecc_corr 27:ecc_uncorr 28:compute_mode 29:pstate
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]),
FanSpeed: parseFloat(parts[10]),
Name: cleanStr(parts[1]),
DriverVersion: cleanStr(parts[4]),
Temperature: parseFloat(parts[6]),
FanSpeed: parseFloat(parts[8]),
Power: parseFloat(parts[9]),
PowerLimit: parseFloat(parts[10]),
PowerMax: parseFloat(parts[13]),
CoreClock: parseFloat(parts[14]),
MemClock: parseFloat(parts[16]),
ClocksMaxGfx: parseFloat(parts[17]),
ClocksMaxMem: parseFloat(parts[18]),
LoadPercent: parseFloat(parts[19]),
UtilMemory: parseFloat(parts[20]),
UtilEncoder: parseFloat(parts[21]),
UtilDecoder: parseFloat(parts[22]),
VRAMTotalMB: parseFloat(parts[23]),
VRAMUsedMB: parseFloat(parts[24]),
ComputeMode: cleanStr(parts[28]),
PState: cleanStr(parts[29]),
})
}
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()
@@ -1148,6 +1322,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
+120 -46
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
@@ -71,26 +72,22 @@ func resolvePath(baseDir, p string) string {
// 配置结构
// ═══════════════════════════════════════════════
type AuthConfig struct {
MinerID string `yaml:"miner_id"`
Password string `yaml:"password"`
}
type ServerConfig struct {
Listen string `yaml:"listen"`
DevicesFile string `yaml:"devices_file"`
Auth AuthConfig `yaml:"auth"`
MinerUID string `yaml:"miner_uid"`
MinerAPIBase string `yaml:"miner_api_base"`
FetchInterval int `yaml:"fetch_interval"`
HistoryMax int `yaml:"history_max"`
Listen string `yaml:"listen"`
DevicesFile string `yaml:"devices_file"`
MinerUID string `yaml:"miner_uid"`
Password string `yaml:"password"`
MinerAPIBase string `yaml:"miner_api_base"`
FetchInterval int `yaml:"fetch_interval"`
HistoryMax int `yaml:"history_max"`
}
func defaultConfig() ServerConfig {
return ServerConfig{
Listen: ":8080",
DevicesFile: "data/devices.yaml",
Auth: AuthConfig{MinerID: "admin", Password: "123456"},
MinerUID: "",
Password: "123456",
MinerAPIBase: "https://pool.kryptex.com/prl/api/v3/miner/workers",
FetchInterval: 30,
HistoryMax: 200,
@@ -195,11 +192,8 @@ func fillDefaults(cfg *ServerConfig) {
if cfg.DevicesFile == "" {
cfg.DevicesFile = "data/devices.yaml"
}
if cfg.Auth.MinerID == "" {
cfg.Auth.MinerID = "admin"
}
if cfg.Auth.Password == "" {
cfg.Auth.Password = "123456"
if cfg.Password == "" {
cfg.Password = "123456"
}
if cfg.MinerAPIBase == "" {
cfg.MinerAPIBase = "https://pool.kryptex.com/prl/api/v3/miner/workers"
@@ -318,15 +312,16 @@ type BrowserCmd struct {
// ═══════════════════════════════════════════════
type Hub struct {
mu sync.RWMutex
fileMu sync.Mutex
records map[string]*DeviceRecord
live map[string]json.RawMessage
monitors map[*websocket.Conn]struct{}
miningData map[string]*MiningData
hashHist map[string][]HashratePoint
devicesAbs string
config ServerConfig
mu sync.RWMutex
fileMu sync.Mutex
records map[string]*DeviceRecord
live map[string]json.RawMessage
monitors map[*websocket.Conn]struct{}
miningData map[string]*MiningData
hashHist map[string][]HashratePoint
devicesAbs string
config ServerConfig
lastWorkerCount int
}
func NewHub(cfg ServerConfig, devicesAbs string) *Hub {
@@ -380,6 +375,72 @@ func (h *Hub) saveYAML() {
os.WriteFile(h.devicesAbs, append([]byte(hdr), out...), 0644)
}
func filterGPURaw(raw json.RawMessage) json.RawMessage {
var data map[string]interface{}
if json.Unmarshal(raw, &data) != nil {
return raw
}
gpusRaw, ok := data["gpus"]
if !ok {
return raw
}
gpus, ok := gpusRaw.([]interface{})
if !ok {
return raw
}
var filtered []interface{}
for _, g := range gpus {
gpu, ok := g.(map[string]interface{})
if !ok {
continue
}
gpuName, _ := gpu["name"].(string)
if isVirtualGPU(gpuName) {
continue
}
filtered = append(filtered, g)
}
data["gpus"] = filtered
out, _ := json.Marshal(data)
return out
}
func isVirtualGPU(name string) bool {
name = strings.ToLower(name)
blacklist := []string{
"microsoft",
"basic render",
"vmware",
"virtual",
"hyper-v",
"parallels",
"virtualbox",
"intel uhd",
"intel iris",
"intel(r) uhd",
"intel(r) iris",
"uhd graphics",
"iris graphics",
"remote",
"rdp",
"citrix",
"teamviewer",
"anydesk",
"parsec",
"splashtop",
"amdgpu-pro",
"oray",
"sunlogin",
"todesk",
}
for _, v := range blacklist {
if strings.Contains(name, v) {
return true
}
}
return false
}
func (h *Hub) UpdateDevice(raw json.RawMessage) {
var p AgentPayload
if json.Unmarshal(raw, &p) != nil || p.MachineName == "" {
@@ -404,7 +465,7 @@ func (h *Hub) UpdateDevice(raw json.RawMessage) {
rec.GPUs = append(rec.GPUs, GPURec{Name: g.Name, VRAM: g.VRAM})
}
cp := *rec
h.live[name] = raw
h.live[name] = filterGPURaw(raw)
var md *MiningData
var hist []HashratePoint
if m, ok := h.miningData[name]; ok {
@@ -422,10 +483,23 @@ func (h *Hub) UpdateDevice(raw json.RawMessage) {
h.saveYAML()
h.broadcast(BrowserMsg{
Type: "device", Machine: name,
Device: &BrowserDevice{Info: &cp, Live: raw, Online: true, Mining: md, History: hist},
Device: &BrowserDevice{Info: &cp, Live: filterGPURaw(raw), Online: true, Mining: md, History: hist},
})
}
func (h *Hub) DeleteDevice(name string) {
h.mu.Lock()
_, ok := h.records[name]
delete(h.records, name)
delete(h.live, name)
h.mu.Unlock()
if ok {
h.saveYAML()
h.broadcast(BrowserMsg{Type: "remove", Machine: name})
logInfo("delete", "%s", name)
}
}
func (h *Hub) MarkOffline(name string) {
h.mu.Lock()
_, had := h.live[name]
@@ -455,19 +529,6 @@ func (h *Hub) MarkOffline(name string) {
}
}
func (h *Hub) DeleteDevice(name string) {
h.mu.Lock()
_, ok := h.records[name]
delete(h.records, name)
delete(h.live, name)
h.mu.Unlock()
if ok {
h.saveYAML()
h.broadcast(BrowserMsg{Type: "remove", Machine: name})
logInfo("delete", "%s", name)
}
}
func (h *Hub) AddMonitor(c *websocket.Conn) {
h.mu.Lock()
h.monitors[c] = struct{}{}
@@ -623,7 +684,13 @@ func (h *Hub) fetchMinerData(url string) {
h.mu.RUnlock()
h.broadcast(BrowserMsg{Type: "mining", Machine: w.Worker, Mining: md, History: hist})
}
logInfo("miner", "更新 %d 个 worker", len(apiResp.Results))
h.mu.Lock()
cnt := len(apiResp.Results)
if cnt != h.lastWorkerCount {
logInfo("miner", "worker 数量变更: %d → %d", h.lastWorkerCount, cnt)
h.lastWorkerCount = cnt
}
h.mu.Unlock()
}
// ═══════════════════════════════════════════════
@@ -639,6 +706,13 @@ var upgrader = websocket.Upgrader{
func handleAgent(conn *websocket.Conn, hub *Hub) {
defer conn.Close()
addr := conn.RemoteAddr().String()
hub.mu.RLock()
monCnt := len(hub.monitors)
hub.mu.RUnlock()
if monCnt == 0 {
logInfo("agent", "拒绝 %s (无浏览器连接)", addr)
return
}
logInfo("agent+", "%s", addr)
defer logInfo("agent-", "%s", addr)
for {
@@ -677,7 +751,7 @@ func handleMonitor(conn *websocket.Conn, hub *Hub) {
}
// 校验
if cmd.MinerID == hub.config.Auth.MinerID && cmd.Password == hub.config.Auth.Password {
if cmd.MinerID == hub.config.MinerUID && cmd.Password == hub.config.Password {
authOK = true
break
}
@@ -784,9 +858,9 @@ func main() {
if len(uid) > 22 {
uid = uid[:22] + "..."
}
fmt.Printf(" ║ Miner : %-29s║\n", uid)
fmt.Printf(" ║ Miner UID : %-29s║\n", uid)
} else {
fmt.Println(" ║ Miner : 未配置 ║")
fmt.Println(" ║ Miner UID : 未配置 ║")
}
fmt.Println(" ╚═══════════════════════════════════════════╝")
fmt.Println()
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -4,9 +4,7 @@ listen: ":8080"
# 设备注册文件
devices_file: "data/devices.yaml"
# ─── 登录认证 ───
auth:
miner_id: "admin"
password: "123456"
password: "adminadmin"
# ─── 矿池配置 ───
# Kryptex 矿池 UID (钱包地址)
miner_uid: "prl1prkg7des097jx9lw22kusw75s0uy0xfmxgmr7l7r67csmpxzad4lqsaajgt"