Compare commits

...

21 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
ngfchl e06b0c1fcd style: heavily compress card size for dense 8-GPU layout 2026-06-19 18:21:12 +08:00
ngfchl 7204f637ac feat: GPU table display + fan speed collection 2026-06-19 17:58:10 +08:00
ngfchl 742928a93d feat: client config from yaml with interval/reconnect settings 2026-06-19 16:46:41 +08:00
ngfchl 22fb7e2c84 docker: use Chinese mirrors to speed up build 2026-06-19 16:40:05 +08:00
ngfchl a289b5308d docker: add docker-compose.yml 2026-06-19 16:34:07 +08:00
7 changed files with 1917 additions and 1348 deletions
+6 -4
View File
@@ -1,10 +1,11 @@
FROM golang:1.26-alpine AS build FROM golang:1.26-alpine AS build
RUN apk add --no-cache git RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache git
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download
COPY . . COPY . .
@@ -18,7 +19,9 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
# ── Runtime ── # ── Runtime ──
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata \
&& rm -rf /var/cache/apk/*
WORKDIR /app WORKDIR /app
COPY --from=build /out/monitor-server . COPY --from=build /out/monitor-server .
@@ -27,7 +30,6 @@ COPY server.yaml .
RUN mkdir -p data RUN mkdir -p data
EXPOSE 8080 EXPOSE 8080
VOLUME ["/app/data"] VOLUME ["/app/data"]
ENTRYPOINT ["./monitor-server"] ENTRYPOINT ["./monitor-server"]
+13
View File
@@ -0,0 +1,13 @@
# Hardware Monitor Client 配置
#
# machine_name : 设备名称 (默认使用主机名)
# server : 服务端地址 (ip:port)
# interval : 采集间隔 (秒, 默认 3)
# reconnect_delay : 断线重连间隔 (秒, 默认 3)
#
# 删除此文件可重新配置
machine_name: my-pc
server: 127.0.0.1:8080
interval: 3
reconnect_delay: 3
+484 -196
View File
@@ -33,34 +33,56 @@ var (
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
type ClientConfig struct { type ClientConfig struct {
MachineName string `yaml:"machine_name"` MachineName string `yaml:"machine_name"`
Server string `yaml:"server"` 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 { func configPath() string {
exe, err := os.Executable() // 优先: 当前工作目录
if err != nil { if wd, err := os.Getwd(); err == nil {
return "client.yaml" p := filepath.Join(wd, "client.yaml")
if _, err := os.Stat(p); err == nil {
return p
}
} }
return filepath.Join(filepath.Dir(exe), "client.yaml") // 其次: 可执行文件目录
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "client.yaml")
}
return "client.yaml"
} }
func loadConfig() (ClientConfig, bool) { func loadConfig() (ClientConfig, bool) {
p := configPath() p := configPath()
raw, err := os.ReadFile(p) raw, err := os.ReadFile(p)
if err != nil { if err != nil {
return ClientConfig{}, false return defaultConfig(), false
} }
var c ClientConfig cfg := defaultConfig()
if yaml.Unmarshal(raw, &c) != nil { if yaml.Unmarshal(raw, &cfg) != nil {
return ClientConfig{}, false return defaultConfig(), false
} }
c.MachineName = strings.TrimSpace(c.MachineName) cfg.MachineName = strings.TrimSpace(cfg.MachineName)
c.Server = strings.TrimSpace(c.Server) cfg.Server = strings.TrimSpace(cfg.Server)
if c.MachineName == "" || c.Server == "" { if cfg.MachineName == "" || cfg.Server == "" {
return ClientConfig{}, false return defaultConfig(), false
} }
return c, true if cfg.Interval <= 0 {
cfg.Interval = 3
}
if cfg.ReconnectDelay <= 0 {
cfg.ReconnectDelay = 3
}
return cfg, true
} }
func saveConfig(c ClientConfig) error { func saveConfig(c ClientConfig) error {
@@ -68,7 +90,14 @@ func saveConfig(c ClientConfig) error {
if err != nil { if err != nil {
return err return err
} }
hdr := "# Monitor Client 配置\n# 删除此文件可重新输入设备信息\n\n" 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() p := configPath()
os.MkdirAll(filepath.Dir(p), 0755) os.MkdirAll(filepath.Dir(p), 0755)
return os.WriteFile(p, append([]byte(hdr), out...), 0644) return os.WriteFile(p, append([]byte(hdr), out...), 0644)
@@ -105,14 +134,25 @@ type MemInfo struct {
Percent float64 `json:"percent"` Percent float64 `json:"percent"`
} }
type GPUInfo struct { type GPUInfo struct {
Name string `json:"name"` Name string `json:"name"`
VRAMTotalMB float64 `json:"vram_total_mb"` VRAMTotalMB float64 `json:"vram_total_mb"`
VRAMUsedMB float64 `json:"vram_used_mb"` VRAMUsedMB float64 `json:"vram_used_mb"`
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
LoadPercent float64 `json:"load_percent"` LoadPercent float64 `json:"load_percent"`
CoreClock float64 `json:"core_clock_mhz"` CoreClock float64 `json:"core_clock_mhz"`
MemClock float64 `json:"mem_clock_mhz"` MemClock float64 `json:"mem_clock_mhz"`
Power float64 `json:"power_watts"` 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"`
} }
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
@@ -414,7 +454,10 @@ func (m model) handleEnter() (tea.Model, tea.Cmd) {
addr += ":8080" addr += ":8080"
} }
m.cfg = ClientConfig{MachineName: name, Server: addr} cfg := defaultConfig()
cfg.MachineName = name
cfg.Server = addr
m.cfg = cfg
saveConfig(m.cfg) saveConfig(m.cfg)
// 切换到监控阶段 // 切换到监控阶段
@@ -510,6 +553,8 @@ func (m model) viewMonitor() string {
if tw < 60 { if tw < 60 {
tw = 60 tw = 60
} }
gap := 2
halfW := (tw - gap) / 2
// ── 顶栏 ── // ── 顶栏 ──
wsDot := "○" wsDot := "○"
@@ -529,47 +574,33 @@ func (m model) viewMonitor() string {
) )
sep := stDim.Render(strings.Repeat("─", tw)) sep := stDim.Render(strings.Repeat("─", tw))
// ── 列宽计算 ── // ── 第一行: CPU + Memory ──
gap := 2 cpuBox := m.viewCPUBox(halfW - 4)
var leftW, rightW int memBox := m.viewMemBox(halfW - 4)
if tw >= 100 { row1 := lipgloss.JoinHorizontal(lipgloss.Top,
leftW = (tw - gap) / 2 cpuBox, strings.Repeat(" ", gap), memBox,
rightW = tw - gap - leftW )
} else {
leftW = tw
rightW = tw
gap = 0
}
innerL := leftW - 4
innerR := rightW - 4
// ── 左列: CPU + History ── // ── 第二行: GPU + History ──
cpuBox := m.viewCPUBox(innerL) gpuBox := m.viewGPUBox(halfW - 4)
histBox := m.viewHistBox(innerL) histBox := m.viewHistBox(halfW - 4)
leftCol := lipgloss.JoinVertical(lipgloss.Left, cpuBox, "", histBox) row2 := lipgloss.JoinHorizontal(lipgloss.Top,
gpuBox, strings.Repeat(" ", gap), 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)
full := lipgloss.JoinVertical(lipgloss.Left, 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 盒子 ── // ── CPU 盒子 ──
@@ -578,40 +609,38 @@ func (m model) viewCPUBox(innerW int) string {
c := m.cpu c := m.cpu
pct := c.UsageTotal pct := c.UsageTotal
// 标题
modelName := c.Model modelName := c.Model
maxNameW := innerW - 10 maxNameW := innerW - 8
if len(modelName) > maxNameW && maxNameW > 3 { if len(modelName) > maxNameW && maxNameW > 3 {
modelName = modelName[:maxNameW-1] + "…" 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 barW := innerW - 8
if barW < 10 { if barW < 10 {
barW = 10 barW = 10
} }
bar := renderBar(pct, barW) 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) 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) coreLine := m.viewCoreHeatmap(innerW)
body := lipgloss.JoinVertical(lipgloss.Left, body := lipgloss.JoinVertical(lipgloss.Left,
titleLine, "", barLine, infoLine, "", coreLine, titleLine, barLine, coreLine,
) )
return stBox.Width(innerW + 4).Render(body) return stBox.Width(innerW + 4).Render(body)
} }
@@ -650,10 +679,16 @@ func (m model) viewCoreHeatmap(innerW int) string {
func (m model) viewMemBox(innerW int) string { func (m model) viewMemBox(innerW int) string {
pct := m.mem.Percent 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 barW := innerW - 8
@@ -661,19 +696,10 @@ func (m model) viewMemBox(innerW int) string {
barW = 10 barW = 10
} }
bar := renderBar(pct, barW) bar := renderBar(pct, barW)
barLine := lipgloss.JoinHorizontal(lipgloss.Center, bar, " ", pctStr) barLine := lipgloss.JoinHorizontal(lipgloss.Center, bar)
// 详情
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, body := lipgloss.JoinVertical(lipgloss.Left,
titleLine, "", pctStr, barLine, "", detailLine, titleLine, barLine,
) )
return stBox.Width(innerW + 4).Render(body) return stBox.Width(innerW + 4).Render(body)
} }
@@ -681,7 +707,7 @@ func (m model) viewMemBox(innerW int) string {
// ── GPU 盒子 ── // ── GPU 盒子 ──
func (m model) viewGPUBox(innerW int) string { 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 { if len(m.gpus) == 0 {
body := lipgloss.JoinVertical(lipgloss.Left, body := lipgloss.JoinVertical(lipgloss.Left,
@@ -690,68 +716,88 @@ func (m model) viewGPUBox(innerW int) string {
return stBox.Width(innerW + 4).Render(body) 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 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 { for i, g := range m.gpus {
if i > 0 { tmpC := tempStyle(&g.Temperature)
blocks = append(blocks, stDim.Render(" "+strings.Repeat("·", innerW-4))) 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...) body := lipgloss.JoinVertical(lipgloss.Left, blocks...)
return stBox.Width(innerW + 4).Render(body) return stBox.Width(innerW + 4).Render(body)
} }
func (m model) viewSingleGPU(g GPUInfo, w int) string { func (m model) avgTemp() float64 {
// 名称 if len(m.gpus) == 0 {
nm := g.Name return 0
if len(nm) > w-2 && w > 5 {
nm = nm[:w-5] + "…"
} }
nameLine := stBold.Render(" " + nm) total := 0.0
for _, g := range m.gpus {
// 统计行 total += g.Temperature
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,
)
} }
return total / float64(len(m.gpus))
return lipgloss.JoinVertical(lipgloss.Left,
nameLine, statsLine, clockLine, vramLine,
)
} }
// ── History 盒子 ── // ── History 盒子 ──
@@ -869,6 +915,15 @@ func runCollector(done <-chan struct{}, cfg ClientConfig, dataCh chan<- MachineD
// 预热 // 预热
cpu.Percent(500*time.Millisecond, true) 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 { for {
select { select {
case <-done: case <-done:
@@ -877,7 +932,7 @@ func runCollector(done <-chan struct{}, cfg ClientConfig, dataCh chan<- MachineD
} }
wsURL := "ws://" + cfg.Server + "/ws/agent" wsURL := "ws://" + cfg.Server + "/ws/agent"
err := collectLoop(done, wsURL, cfg.MachineName, dataCh, statCh) err := collectLoop(done, wsURL, cfg.MachineName, interval, dataCh, statCh)
if err != nil { if err != nil {
select { select {
case statCh <- false: case statCh <- false:
@@ -889,12 +944,12 @@ func runCollector(done <-chan struct{}, cfg ClientConfig, dataCh chan<- MachineD
select { select {
case <-done: case <-done:
return return
case <-time.After(3 * time.Second): case <-time.After(reconnect):
} }
} }
} }
func collectLoop(done <-chan struct{}, wsURL, name string, dataCh chan<- MachineData, statCh chan<- bool) error { 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) conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil { if err != nil {
return err return err
@@ -913,8 +968,8 @@ func collectLoop(done <-chan struct{}, wsURL, name string, dataCh chan<- Machine
default: default:
} }
// 采集 CPU 使用率 (阻塞 ~3s) // 采集 CPU 使用率
perCore, _ := cpu.Percent(3*time.Second, true) perCore, _ := cpu.Percent(interval, true)
data := collectAll(name, perCore) data := collectAll(name, perCore)
@@ -926,6 +981,9 @@ func collectLoop(done <-chan struct{}, wsURL, name string, dataCh chan<- Machine
case dataCh <- data: case dataCh <- data:
default: // 满了就丢弃 default: // 满了就丢弃
} }
// 每 10 轮触发一次 GC,减少内存占用
runtime.GC()
} }
} }
@@ -972,27 +1030,72 @@ func collectCPU(perCore []float64) CPUInfo {
} }
func getCPUTemp() *float64 { func getCPUTemp() *float64 {
if runtime.GOOS != "windows" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command",
"(Get-CimInstance MSAcpi_ThermalZoneTemperature -Namespace 'root/wmi' -EA SilentlyContinue | Select -First 1).CurrentTemperature", switch runtime.GOOS {
).Output() case "windows":
if err != nil { out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command",
return nil "(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)) return nil
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
} }
func collectMemory() MemInfo { func collectMemory() MemInfo {
@@ -1008,43 +1111,119 @@ func collectMemory() MemInfo {
} }
func collectMotherboard() BoardInfo { func collectMotherboard() BoardInfo {
if runtime.GOOS != "windows" {
return BoardInfo{}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
out, err := exec.CommandContext(ctx, "wmic", "baseboard", "get", "manufacturer,product", "/format:list").Output()
if err != nil { switch runtime.GOOS {
return BoardInfo{} case "windows":
} out, err := exec.CommandContext(ctx, "wmic", "baseboard", "get", "manufacturer,product", "/format:list").Output()
var b BoardInfo if err != nil {
for _, line := range strings.Split(string(out), "\n") { return BoardInfo{}
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=")
} }
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 { func collectGPUs() []GPUInfo {
if runtime.GOOS == "windows" { switch runtime.GOOS {
case "windows":
gpus := tryNvidiaSMI() gpus := tryNvidiaSMI()
if len(gpus) > 0 { if len(gpus) > 0 {
return gpus 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() 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 { func tryNvidiaSMI() []GPUInfo {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
out, err := exec.CommandContext(ctx, "nvidia-smi", 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", "--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", "--format=csv,noheader,nounits",
).Output() ).Output()
if err != nil { if err != nil {
@@ -1053,26 +1232,64 @@ func tryNvidiaSMI() []GPUInfo {
var gpus []GPUInfo var gpus []GPUInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.Split(line, ",") parts := strings.Split(line, ",")
if len(parts) < 10 { if len(parts) < 24 {
continue continue
} }
for i := range parts { for i := range parts {
parts[i] = strings.TrimSpace(parts[i]) 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{ gpus = append(gpus, GPUInfo{
Name: parts[1], Name: cleanStr(parts[1]),
VRAMTotalMB: parseFloat(parts[2]), DriverVersion: cleanStr(parts[4]),
VRAMUsedMB: parseFloat(parts[3]), Temperature: parseFloat(parts[6]),
Temperature: parseFloat(parts[5]), FanSpeed: parseFloat(parts[8]),
LoadPercent: parseFloat(parts[6]), Power: parseFloat(parts[9]),
CoreClock: parseFloat(parts[7]), PowerLimit: parseFloat(parts[10]),
MemClock: parseFloat(parts[8]), PowerMax: parseFloat(parts[13]),
Power: parseFloat(parts[9]), 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 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 { func tryWMIGPU() []GPUInfo {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -1105,6 +1322,76 @@ func tryWMIGPU() []GPUInfo {
return gpus 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 { func parseFloat(s string) float64 {
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v return v
@@ -1130,9 +1417,10 @@ func main() {
cfg, loaded := loadConfig() cfg, loaded := loadConfig()
if loaded { if loaded {
fmt.Printf(" [✓] 加载配置: %s\n", configPath()) fmt.Printf(" [✓] 配置文件: %s\n", configPath())
fmt.Printf(" [✓] 设备名称: %s\n", cfg.MachineName) fmt.Printf(" [✓] 设备名称: %s\n", cfg.MachineName)
fmt.Printf(" [✓] 服务端 : %s\n", cfg.Server) fmt.Printf(" [✓] 服务端 : %s\n", cfg.Server)
fmt.Printf(" [✓] 采集间隔: %ds\n", cfg.Interval)
fmt.Println() fmt.Println()
} }
+120 -46
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "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 { type ServerConfig struct {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
DevicesFile string `yaml:"devices_file"` DevicesFile string `yaml:"devices_file"`
Auth AuthConfig `yaml:"auth"` MinerUID string `yaml:"miner_uid"`
MinerUID string `yaml:"miner_uid"` Password string `yaml:"password"`
MinerAPIBase string `yaml:"miner_api_base"` MinerAPIBase string `yaml:"miner_api_base"`
FetchInterval int `yaml:"fetch_interval"` FetchInterval int `yaml:"fetch_interval"`
HistoryMax int `yaml:"history_max"` HistoryMax int `yaml:"history_max"`
} }
func defaultConfig() ServerConfig { func defaultConfig() ServerConfig {
return ServerConfig{ return ServerConfig{
Listen: ":8080", Listen: ":8080",
DevicesFile: "data/devices.yaml", DevicesFile: "data/devices.yaml",
Auth: AuthConfig{MinerID: "admin", Password: "123456"}, MinerUID: "",
Password: "123456",
MinerAPIBase: "https://pool.kryptex.com/prl/api/v3/miner/workers", MinerAPIBase: "https://pool.kryptex.com/prl/api/v3/miner/workers",
FetchInterval: 30, FetchInterval: 30,
HistoryMax: 200, HistoryMax: 200,
@@ -195,11 +192,8 @@ func fillDefaults(cfg *ServerConfig) {
if cfg.DevicesFile == "" { if cfg.DevicesFile == "" {
cfg.DevicesFile = "data/devices.yaml" cfg.DevicesFile = "data/devices.yaml"
} }
if cfg.Auth.MinerID == "" { if cfg.Password == "" {
cfg.Auth.MinerID = "admin" cfg.Password = "123456"
}
if cfg.Auth.Password == "" {
cfg.Auth.Password = "123456"
} }
if cfg.MinerAPIBase == "" { if cfg.MinerAPIBase == "" {
cfg.MinerAPIBase = "https://pool.kryptex.com/prl/api/v3/miner/workers" cfg.MinerAPIBase = "https://pool.kryptex.com/prl/api/v3/miner/workers"
@@ -318,15 +312,16 @@ type BrowserCmd struct {
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
type Hub struct { type Hub struct {
mu sync.RWMutex mu sync.RWMutex
fileMu sync.Mutex fileMu sync.Mutex
records map[string]*DeviceRecord records map[string]*DeviceRecord
live map[string]json.RawMessage live map[string]json.RawMessage
monitors map[*websocket.Conn]struct{} monitors map[*websocket.Conn]struct{}
miningData map[string]*MiningData miningData map[string]*MiningData
hashHist map[string][]HashratePoint hashHist map[string][]HashratePoint
devicesAbs string devicesAbs string
config ServerConfig config ServerConfig
lastWorkerCount int
} }
func NewHub(cfg ServerConfig, devicesAbs string) *Hub { func NewHub(cfg ServerConfig, devicesAbs string) *Hub {
@@ -380,6 +375,72 @@ func (h *Hub) saveYAML() {
os.WriteFile(h.devicesAbs, append([]byte(hdr), out...), 0644) 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) { func (h *Hub) UpdateDevice(raw json.RawMessage) {
var p AgentPayload var p AgentPayload
if json.Unmarshal(raw, &p) != nil || p.MachineName == "" { 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}) rec.GPUs = append(rec.GPUs, GPURec{Name: g.Name, VRAM: g.VRAM})
} }
cp := *rec cp := *rec
h.live[name] = raw h.live[name] = filterGPURaw(raw)
var md *MiningData var md *MiningData
var hist []HashratePoint var hist []HashratePoint
if m, ok := h.miningData[name]; ok { if m, ok := h.miningData[name]; ok {
@@ -422,10 +483,23 @@ func (h *Hub) UpdateDevice(raw json.RawMessage) {
h.saveYAML() h.saveYAML()
h.broadcast(BrowserMsg{ h.broadcast(BrowserMsg{
Type: "device", Machine: name, 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) { func (h *Hub) MarkOffline(name string) {
h.mu.Lock() h.mu.Lock()
_, had := h.live[name] _, 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) { func (h *Hub) AddMonitor(c *websocket.Conn) {
h.mu.Lock() h.mu.Lock()
h.monitors[c] = struct{}{} h.monitors[c] = struct{}{}
@@ -623,7 +684,13 @@ func (h *Hub) fetchMinerData(url string) {
h.mu.RUnlock() h.mu.RUnlock()
h.broadcast(BrowserMsg{Type: "mining", Machine: w.Worker, Mining: md, History: hist}) 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) { func handleAgent(conn *websocket.Conn, hub *Hub) {
defer conn.Close() defer conn.Close()
addr := conn.RemoteAddr().String() 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) logInfo("agent+", "%s", addr)
defer logInfo("agent-", "%s", addr) defer logInfo("agent-", "%s", addr)
for { 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 authOK = true
break break
} }
@@ -784,9 +858,9 @@ func main() {
if len(uid) > 22 { if len(uid) > 22 {
uid = uid[:22] + "..." uid = uid[:22] + "..."
} }
fmt.Printf(" ║ Miner : %-29s║\n", uid) fmt.Printf(" ║ Miner UID : %-29s║\n", uid)
} else { } else {
fmt.Println(" ║ Miner : 未配置 ║") fmt.Println(" ║ Miner UID : 未配置 ║")
} }
fmt.Println(" ╚═══════════════════════════════════════════╝") fmt.Println(" ╚═══════════════════════════════════════════╝")
fmt.Println() fmt.Println()
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
services:
server:
build:
context: .
dockerfile: Dockerfile
args:
VERSION: "1.0.0"
container_name: bitmonitor-server
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./server.yaml:/app/server.yaml:ro
environment:
- TZ=Asia/Shanghai
+1 -3
View File
@@ -4,9 +4,7 @@ listen: ":8080"
# 设备注册文件 # 设备注册文件
devices_file: "data/devices.yaml" devices_file: "data/devices.yaml"
# ─── 登录认证 ─── # ─── 登录认证 ───
auth: password: "adminadmin"
miner_id: "admin"
password: "123456"
# ─── 矿池配置 ─── # ─── 矿池配置 ───
# Kryptex 矿池 UID (钱包地址) # Kryptex 矿池 UID (钱包地址)
miner_uid: "prl1prkg7des097jx9lw22kusw75s0uy0xfmxgmr7l7r67csmpxzad4lqsaajgt" miner_uid: "prl1prkg7des097jx9lw22kusw75s0uy0xfmxgmr7l7r67csmpxzad4lqsaajgt"