From 52c5edc9d4b4f45d42d59723ed79741616dfee68 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Fri, 19 Jun 2026 14:30:02 +0800 Subject: [PATCH] init: Hardware Monitor server + client + web dashboard --- .gitignore | 6 +- cmd/client/main.go | 1145 ++++++++++++++++++++++ cmd/server/main.go | 802 ++++++++++++++++ cmd/server/server.yaml | 21 + cmd/server/templates/index.html | 1578 +++++++++++++++++++++++++++++++ 5 files changed, 3549 insertions(+), 3 deletions(-) create mode 100644 cmd/client/main.go create mode 100644 cmd/server/main.go create mode 100644 cmd/server/server.yaml create mode 100644 cmd/server/templates/index.html diff --git a/.gitignore b/.gitignore index 29d6030..a156bbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Build -BitMonitor -client -server +/BitMonitor +/client +/server *.exe *.test *.out diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..a8ce741 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,1145 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/gorilla/websocket" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/mem" + "gopkg.in/yaml.v3" +) + +var ( + Version = "dev" + BuildTime = "unknown" +) + +// ═══════════════════════════════════════════════ +// Config (YAML 持久化) +// ═══════════════════════════════════════════════ + +type ClientConfig struct { + MachineName string `yaml:"machine_name"` + Server string `yaml:"server"` +} + +func configPath() string { + exe, err := os.Executable() + if err != nil { + return "client.yaml" + } + return filepath.Join(filepath.Dir(exe), "client.yaml") +} + +func loadConfig() (ClientConfig, bool) { + p := configPath() + raw, err := os.ReadFile(p) + if err != nil { + return ClientConfig{}, false + } + var c ClientConfig + if yaml.Unmarshal(raw, &c) != nil { + return ClientConfig{}, false + } + c.MachineName = strings.TrimSpace(c.MachineName) + c.Server = strings.TrimSpace(c.Server) + if c.MachineName == "" || c.Server == "" { + return ClientConfig{}, false + } + return c, true +} + +func saveConfig(c ClientConfig) error { + out, err := yaml.Marshal(&c) + if err != nil { + return err + } + hdr := "# Monitor Client 配置\n# 删除此文件可重新输入设备信息\n\n" + p := configPath() + os.MkdirAll(filepath.Dir(p), 0755) + return os.WriteFile(p, append([]byte(hdr), out...), 0644) +} + +// ═══════════════════════════════════════════════ +// 数据结构 +// ═══════════════════════════════════════════════ + +type MachineData struct { + MachineName string `json:"machine_name"` + Motherboard BoardInfo `json:"motherboard"` + CPU CPUInfo `json:"cpu"` + Memory MemInfo `json:"memory"` + GPUs []GPUInfo `json:"gpus"` + Timestamp time.Time `json:"timestamp"` +} +type BoardInfo struct { + Manufacturer string `json:"manufacturer"` + Product string `json:"product"` +} +type CPUInfo struct { + Model string `json:"model"` + CoresPhysical int `json:"cores_physical"` + CoresLogical int `json:"cores_logical"` + FreqMHz float64 `json:"freq_current_mhz"` + Temperature *float64 `json:"temperature"` + UsageTotal float64 `json:"usage_total"` + UsagePerCore []float64 `json:"usage_per_core"` +} +type MemInfo struct { + TotalBytes uint64 `json:"total_bytes"` + UsedBytes uint64 `json:"used_bytes"` + 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"` +} + +// ═══════════════════════════════════════════════ +// 样式 (全部使用 lipgloss.Style,非 TerminalColor) +// ═══════════════════════════════════════════════ + +var ( + sAccent = lipgloss.Color("#00e5a0") + sWarn = lipgloss.Color("#ffb020") + sDanger = lipgloss.Color("#ff4060") + sDim = lipgloss.Color("#5c6480") + sFg = lipgloss.Color("#e4e8f1") + sBorder = lipgloss.Color("#333852") + sDimBar = lipgloss.Color("#2a2e3e") + sSurface = lipgloss.Color("#181c2a") + + stTitle = lipgloss.NewStyle().Bold(true).Foreground(sAccent) + stDim = lipgloss.NewStyle().Foreground(sDim) + stFg = lipgloss.NewStyle().Foreground(sFg) + stBold = lipgloss.NewStyle().Bold(true) + stLabel = lipgloss.NewStyle().Foreground(sDim) + stVal = lipgloss.NewStyle().Foreground(sFg) + + stBox = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(sBorder). + Padding(0, 1) + + stBoxInner = lipgloss.NewStyle().Padding(0, 0) +) + +func pctStyle(p float64) lipgloss.Style { + if p >= 80 { + return lipgloss.NewStyle().Foreground(sDanger) + } + if p >= 60 { + return lipgloss.NewStyle().Foreground(sWarn) + } + return lipgloss.NewStyle().Foreground(sAccent) +} + +func tempStyle(t *float64) lipgloss.Style { + if t == nil || *t <= 0 { + return stFg + } + if *t >= 85 { + return lipgloss.NewStyle().Foreground(sDanger) + } + if *t >= 70 { + return lipgloss.NewStyle().Foreground(sWarn) + } + return stFg +} + +// ═══════════════════════════════════════════════ +// Bubbletea 消息 +// ═══════════════════════════════════════════════ + +type tickMsg time.Time +type dataMsg struct{ data MachineData } +type wsStatusMsg struct{ online bool } +type collectorReadyMsg struct{} + +// ═══════════════════════════════════════════════ +// Model +// ═══════════════════════════════════════════════ + +const histLen = 60 + +var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +type model struct { + // 阶段: 0=输入, 1=监控 + phase int + + // 输入框 + nameIn textinput.Model + addrIn textinput.Model + focus int // 0 or 1 + errMsg string + + // 配置 + cfg ClientConfig + cfgPath string + + // 连接状态 + wsOnline bool + + // 通道 + dataCh chan MachineData + statCh chan bool + doneCh chan struct{} // 关闭即停止采集 + + // 最新数据 + cpu CPUInfo + mem MemInfo + gpus []GPUInfo + + // 历史 + cpuH []float64 + memH []float64 + gpuLH []float64 + + // 终端尺寸 + w, h int +} + +func newModel(hostname string, cfg ClientConfig, loaded bool) model { + // 输入框: 设备名 + ni := textinput.New() + ni.Placeholder = hostname + ni.Prompt = " ❯ " + ni.CharLimit = 64 + ni.Width = 38 + ni.SetValue(hostname) + + // 输入框: 服务端地址 + ai := textinput.New() + ai.Placeholder = "localhost:8080" + ai.Prompt = " ❯ " + ai.CharLimit = 128 + ai.Width = 38 + ai.SetValue("localhost:8080") + + p := 0 + if loaded { + p = 1 + ni.SetValue(cfg.MachineName) + ai.SetValue(cfg.Server) + } else { + ni.Focus() + } + + m := model{ + phase: p, + nameIn: ni, + addrIn: ai, + cfg: cfg, + cfgPath: configPath(), + dataCh: make(chan MachineData, 4), + statCh: make(chan bool, 2), + doneCh: make(chan struct{}), + cpuH: make([]float64, 0, histLen), + memH: make([]float64, 0, histLen), + gpuLH: make([]float64, 0, histLen), + w: 100, + h: 30, + } + return m +} + +// ═══════════════════════════════════════════════ +// Commands +// ═══════════════════════════════════════════════ + +// listenCmd 阻塞等待 data/stat/timeout,返回对应消息 +func (m model) listenCmd() tea.Cmd { + dc, sc := m.dataCh, m.statCh + return func() tea.Msg { + select { + case d := <-dc: + return dataMsg{data: d} + case s := <-sc: + return wsStatusMsg{online: s} + case <-time.After(time.Second): + return tickMsg(time.Now()) + } + } +} + +// startCollectorCmd 启动采集 goroutine +func (m model) startCollectorCmd() tea.Cmd { + cfg := m.cfg + dc := m.dataCh + sc := m.statCh + done := m.doneCh + return func() tea.Msg { + go runCollector(done, cfg, dc, sc) + return collectorReadyMsg{} + } +} + +// ═══════════════════════════════════════════════ +// Init +// ═══════════════════════════════════════════════ + +func (m model) Init() tea.Cmd { + cmds := []tea.Cmd{tea.EnterAltScreen} + if m.phase == 1 { + cmds = append(cmds, m.startCollectorCmd(), m.listenCmd()) + } else { + cmds = append(cmds, textinput.Blink) + } + return tea.Batch(cmds...) +} + +// ═══════════════════════════════════════════════ +// Update +// ═══════════════════════════════════════════════ + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.w, m.h = msg.Width, msg.Height + return m, nil + + case collectorReadyMsg: + return m, nil + + case tickMsg: + if m.phase == 1 { + return m, m.listenCmd() + } + return m, nil + + case dataMsg: + m.cpu = msg.data.CPU + m.mem = msg.data.Memory + m.gpus = msg.data.GPUs + m.pushHistory() + if m.phase == 1 { + return m, m.listenCmd() + } + return m, nil + + case wsStatusMsg: + m.wsOnline = msg.online + if m.phase == 1 { + return m, m.listenCmd() + } + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + + // 非按键消息转发给 textinput (光标闪烁等) + if m.phase == 0 { + var cmd tea.Cmd + if m.focus == 0 { + m.nameIn, cmd = m.nameIn.Update(msg) + } else { + m.addrIn, cmd = m.addrIn.Update(msg) + } + return m, cmd + } + return m, nil +} + +func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + + if m.phase == 0 { + return m.handleInputKey(msg) + } + return m.handleMonitorKey(msg) +} + +func (m model) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + return m.handleEnter() + case "tab", "down": + m.focus = 1 + m.nameIn.Blur() + m.addrIn.Focus() + return m, nil + case "up", "shift+tab": + m.focus = 0 + m.nameIn.Focus() + m.addrIn.Blur() + return m, nil + } + // 其余按键转发给当前聚焦的输入框 + var cmd tea.Cmd + if m.focus == 0 { + m.nameIn, cmd = m.nameIn.Update(msg) + } else { + m.addrIn, cmd = m.addrIn.Update(msg) + } + return m, cmd +} + +func (m model) handleEnter() (tea.Model, tea.Cmd) { + name := strings.TrimSpace(m.nameIn.Value()) + addr := strings.TrimSpace(m.addrIn.Value()) + if name == "" { + m.errMsg = "设备名称不能为空" + return m, nil + } + if addr == "" { + m.errMsg = "服务端地址不能为空" + return m, nil + } + if !strings.Contains(addr, ":") { + addr += ":8080" + } + + m.cfg = ClientConfig{MachineName: name, Server: addr} + saveConfig(m.cfg) + + // 切换到监控阶段 + m.phase = 1 + m.errMsg = "" + m.dataCh = make(chan MachineData, 4) + m.statCh = make(chan bool, 2) + m.wsOnline = false + + return m, tea.Batch(m.startCollectorCmd(), m.listenCmd()) +} + +func (m model) handleMonitorKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + return m, tea.Quit + case "r": + // 停止采集 goroutine + close(m.doneCh) + m.doneCh = make(chan struct{}) + // 回到输入阶段 + m.phase = 0 + m.wsOnline = false + m.nameIn.SetValue(m.cfg.MachineName) + m.addrIn.SetValue(m.cfg.Server) + m.focus = 0 + m.nameIn.Focus() + m.addrIn.Blur() + return m, textinput.Blink + } + return m, nil +} + +// ═══════════════════════════════════════════════ +// 历史数据 +// ═══════════════════════════════════════════════ + +func (m *model) pushHistory() { + m.cpuH = appendHist(m.cpuH, m.cpu.UsageTotal) + m.memH = appendHist(m.memH, m.mem.Percent) + if len(m.gpus) > 0 { + m.gpuLH = appendHist(m.gpuLH, m.gpus[0].LoadPercent) + } +} + +func appendHist(h []float64, v float64) []float64 { + h = append(h, v) + if len(h) > histLen { + h = h[len(h)-histLen:] + } + return h +} + +// ═══════════════════════════════════════════════ +// View +// ═══════════════════════════════════════════════ + +func (m model) View() string { + if m.phase == 0 { + return m.viewInput() + } + return m.viewMonitor() +} + +// ── 输入界面 ── + +func (m model) viewInput() string { + title := stTitle.Render(" HARDWARE MONITOR CLIENT ") + sub := stDim.Render("输入设备信息以连接服务端") + div := stDim.Render(strings.Repeat("─", 46)) + + content := lipgloss.JoinVertical(lipgloss.Left, + title, sub, div, "", + stLabel.Render(" 设备名称"), + m.nameIn.View(), "", + stLabel.Render(" 服务端地址"), + m.addrIn.View(), + ) + + if m.errMsg != "" { + content += "\n" + lipgloss.NewStyle().Foreground(sDanger).Render(" ✗ "+m.errMsg) + } + content += "\n" + stDim.Render(" Tab 切换 · Enter 确认 · Ctrl+C 退出") + + box := stBox.Render(content) + return lipgloss.Place(m.w, m.h, lipgloss.Center, lipgloss.Center, box) +} + +// ── 监控主界面 ── + +func (m model) viewMonitor() string { + tw := m.w + if tw < 60 { + tw = 60 + } + + // ── 顶栏 ── + wsDot := "○" + wsTxt := "未连接" + wsC := lipgloss.NewStyle().Foreground(sDanger) + if m.wsOnline { + wsDot = "●" + wsTxt = "已连接" + wsC = lipgloss.NewStyle().Foreground(sAccent) + } + header := lipgloss.JoinHorizontal(lipgloss.Left, + stTitle.Render(" SYSTEM MONITOR "), + " ", + wsC.Render(wsDot+" "+wsTxt), + stDim.Render(" │ "+m.cfg.MachineName+" │ "), + stDim.Render(time.Now().Format("15:04:05")), + ) + 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 + 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) + + full := lipgloss.JoinVertical(lipgloss.Left, + header, sep, "", mainContent, "", sep, footer, + ) + return full +} + +// ── CPU 盒子 ── + +func (m model) viewCPUBox(innerW int) string { + c := m.cpu + pct := c.UsageTotal + + // 标题 + modelName := c.Model + maxNameW := innerW - 10 + if len(modelName) > maxNameW && maxNameW > 3 { + modelName = modelName[:maxNameW-1] + "…" + } + titleLine := stTitle.Render("CPU") + " " + stFg.Render(modelName) + + // 百分比进度条 + barW := innerW - 8 + if barW < 10 { + barW = 10 + } + bar := renderBar(pct, barW) + pctStr := pctStyle(pct).Bold(true).Render(fmt.Sprintf("%5.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, + ) + return stBox.Width(innerW + 4).Render(body) +} + +func (m model) viewCoreHeatmap(innerW int) string { + cores := m.cpu.UsagePerCore + if len(cores) == 0 { + return stDim.Render(" 核心占用 (无数据)") + } + lbl := stDim.Render(" 核心 ") + graphW := innerW - lipgloss.Width(lbl) + if graphW < 1 { + graphW = 1 + } + + var sb strings.Builder + sb.WriteString(lbl) + for i, p := range cores { + if i >= graphW { + break + } + idx := int(p / 100 * float64(len(sparkChars)-1)) + idx = clamp(idx, 0, len(sparkChars)-1) + sb.WriteString(pctStyle(p).Render(string(sparkChars[idx]))) + } + // 补点 + if len(cores) < graphW { + dim := lipgloss.NewStyle().Foreground(sDimBar) + sb.WriteString(dim.Render(strings.Repeat("·", graphW-len(cores)))) + } + return sb.String() +} + +// ── Memory 盒子 ── + +func (m model) viewMemBox(innerW int) string { + pct := m.mem.Percent + + titleLine := stTitle.Render("MEMORY") + + // 百分比 + pctStr := pctStyle(pct).Bold(true).Render(fmt.Sprintf("%.1f%%", pct)) + + // 进度条 + barW := innerW - 8 + if barW < 10 { + 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), + ) + + body := lipgloss.JoinVertical(lipgloss.Left, + titleLine, "", pctStr, barLine, "", detailLine, + ) + return stBox.Width(innerW + 4).Render(body) +} + +// ── GPU 盒子 ── + +func (m model) viewGPUBox(innerW int) string { + titleLine := stTitle.Render("GPU") + + if len(m.gpus) == 0 { + body := lipgloss.JoinVertical(lipgloss.Left, + titleLine, "", stDim.Render(" 未检测到 GPU"), + ) + return stBox.Width(innerW + 4).Render(body) + } + + var blocks []string + blocks = append(blocks, titleLine, "") + + for i, g := range m.gpus { + if i > 0 { + blocks = append(blocks, stDim.Render(" "+strings.Repeat("·", innerW-4))) + } + blocks = append(blocks, m.viewSingleGPU(g, innerW)) + } + + 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] + "…" + } + 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, + ) + } + + return lipgloss.JoinVertical(lipgloss.Left, + nameLine, statsLine, clockLine, vramLine, + ) +} + +// ── History 盒子 ── + +func (m model) viewHistBox(innerW int) string { + titleLine := stTitle.Render("HISTORY") + stDim.Render(" (最近 60 采样)") + + graphW := innerW - 8 + if graphW < 8 { + graphW = 8 + } + + sparkCPU := renderSparkRow("CPU ", m.cpuH, graphW, lipgloss.NewStyle().Foreground(sAccent)) + sparkMEM := renderSparkRow("MEM ", m.memH, graphW, lipgloss.NewStyle().Foreground(sWarn)) + sparkGPU := renderSparkRow("GPU ", m.gpuLH, graphW, lipgloss.NewStyle().Foreground(sAccent)) + + body := lipgloss.JoinVertical(lipgloss.Left, + titleLine, "", sparkCPU, sparkMEM, sparkGPU, + ) + return stBox.Width(innerW + 4).Render(body) +} + +// ═══════════════════════════════════════════════ +// 渲染工具函数 +// ═══════════════════════════════════════════════ + +func renderBar(pct float64, width int) string { + pct = clampF(pct, 0, 100) + filled := int(math.Round(float64(width) * pct / 100)) + filled = clamp(filled, 0, width) + style := pctStyle(pct) + dimStyle := lipgloss.NewStyle().Foreground(sDimBar) + return style.Render(strings.Repeat("█", filled)) + + dimStyle.Render(strings.Repeat("░", width-filled)) +} + +func renderSparkRow(label string, data []float64, graphW int, style lipgloss.Style) string { + lbl := stDim.Render(" " + label) + gw := graphW - 2 + if gw < 4 { + gw = 4 + } + + dimDot := lipgloss.NewStyle().Foreground(sDimBar) + + if len(data) == 0 { + return lbl + dimDot.Render(strings.Repeat("·", gw)) + } + d := data + if len(d) > gw { + d = d[len(d)-gw:] + } + + var sb strings.Builder + sb.WriteString(lbl) + pad := gw - len(d) + if pad > 0 { + sb.WriteString(dimDot.Render(strings.Repeat("·", pad))) + } + for _, v := range d { + idx := int(v / 100 * float64(len(sparkChars)-1)) + idx = clamp(idx, 0, len(sparkChars)-1) + sb.WriteString(style.Render(string(sparkChars[idx]))) + } + return sb.String() +} + +func tempText(t *float64) string { + if t == nil || *t <= 0 { + return "N/A" + } + return fmt.Sprintf("%.1f°C", *t) +} + +func fmtBytes(b uint64) string { + if b == 0 { + return "0 B" + } + units := []string{"B", "KB", "MB", "GB", "TB"} + f := float64(b) + for _, u := range units { + if f < 1024 { + return fmt.Sprintf("%.1f %s", f, u) + } + f /= 1024 + } + return fmt.Sprintf("%.1f PB", f) +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func clampF(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// ═══════════════════════════════════════════════ +// 数据采集 goroutine +// ═══════════════════════════════════════════════ + +func runCollector(done <-chan struct{}, cfg ClientConfig, dataCh chan<- MachineData, statCh chan<- bool) { + // 预热 + cpu.Percent(500*time.Millisecond, true) + + for { + select { + case <-done: + return + default: + } + + wsURL := "ws://" + cfg.Server + "/ws/agent" + err := collectLoop(done, wsURL, cfg.MachineName, dataCh, statCh) + if err != nil { + select { + case statCh <- false: + default: + } + } + + // 断线重连等待 + select { + case <-done: + return + case <-time.After(3 * time.Second): + } + } +} + +func collectLoop(done <-chan struct{}, wsURL, name string, dataCh chan<- MachineData, statCh chan<- bool) error { + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return err + } + defer conn.Close() + + select { + case statCh <- true: + default: + } + + for { + select { + case <-done: + return nil + default: + } + + // 采集 CPU 使用率 (阻塞 ~3s) + perCore, _ := cpu.Percent(3*time.Second, true) + + data := collectAll(name, perCore) + + if err := conn.WriteJSON(data); err != nil { + return err + } + + select { + case dataCh <- data: + default: // 满了就丢弃 + } + } +} + +func collectAll(name string, perCore []float64) MachineData { + return MachineData{ + MachineName: name, + Motherboard: collectMotherboard(), + CPU: collectCPU(perCore), + Memory: collectMemory(), + GPUs: collectGPUs(), + Timestamp: time.Now(), + } +} + +func collectCPU(perCore []float64) CPUInfo { + pC, _ := cpu.Counts(false) + lC, _ := cpu.Counts(true) + + var total float64 + for _, p := range perCore { + total += p + } + if len(perCore) > 0 { + total /= float64(len(perCore)) + } + + modelName := "Unknown CPU" + freq := 0.0 + infos, err := cpu.Info() + if err == nil && len(infos) > 0 { + modelName = strings.TrimSpace(infos[0].ModelName) + freq = infos[0].Mhz + } + + return CPUInfo{ + Model: modelName, + CoresPhysical: pC, + CoresLogical: lC, + FreqMHz: freq, + Temperature: getCPUTemp(), + UsageTotal: math.Round(total*10) / 10, + UsagePerCore: perCore, + } +} + +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 + } + 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 +} + +func collectMemory() MemInfo { + vm, err := mem.VirtualMemory() + if err != nil { + return MemInfo{} + } + return MemInfo{ + TotalBytes: vm.Total, + UsedBytes: vm.Used, + Percent: math.Round(vm.UsedPercent*10) / 10, + } +} + +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=") + } + } + return b +} + +func collectGPUs() []GPUInfo { + if runtime.GOOS == "windows" { + gpus := tryNvidiaSMI() + if len(gpus) > 0 { + return gpus + } + return tryWMIGPU() + } + return tryNvidiaSMI() +} + +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", + "--format=csv,noheader,nounits", + ).Output() + if err != nil { + return nil + } + var gpus []GPUInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.Split(line, ",") + if len(parts) < 10 { + continue + } + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + 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]), + }) + } + return gpus +} + +func tryWMIGPU() []GPUInfo { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_VideoController | Select Name,AdapterRAM | ConvertTo-Json -Compress", + ).Output() + if err != nil { + return nil + } + type wg struct { + Name string `json:"Name"` + AdapterRAM int64 `json:"AdapterRAM"` + } + s := strings.TrimSpace(string(out)) + if !strings.HasPrefix(s, "[") { + s = "[" + s + "]" + } + var items []wg + if json.Unmarshal([]byte(s), &items) != nil { + return nil + } + var gpus []GPUInfo + for _, item := range items { + var ram float64 + if item.AdapterRAM > 0 { + ram = float64(item.AdapterRAM) / (1024 * 1024) + } + gpus = append(gpus, GPUInfo{Name: item.Name, VRAMTotalMB: ram}) + } + return gpus +} + +func parseFloat(s string) float64 { + v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + return v +} + +// ═══════════════════════════════════════════════ +// main +// ═══════════════════════════════════════════════ + +func main() { + runtime.GOMAXPROCS(4) + + showVersion := flag.Bool("version", false, "显示版本信息") + flag.Parse() + + if *showVersion { + fmt.Printf("Hardware Monitor Client %s\n", Version) + fmt.Printf("Build: %s / %s\n", BuildTime, runtime.GOOS+"/"+runtime.GOARCH) + return + } + + hostname, _ := os.Hostname() + cfg, loaded := loadConfig() + + if loaded { + fmt.Printf(" [✓] 加载配置: %s\n", configPath()) + fmt.Printf(" [✓] 设备名称: %s\n", cfg.MachineName) + fmt.Printf(" [✓] 服务端 : %s\n", cfg.Server) + fmt.Println() + } + + m := newModel(hostname, cfg, loaded) + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "错误: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..72b1e6b --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,802 @@ +package main + +import ( + _ "embed" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "sync" + "time" + + "github.com/gorilla/websocket" + "gopkg.in/yaml.v3" +) + +// ═══════════════════════════════════════════════ +// 日志 +// ═══════════════════════════════════════════════ + +func init() { + log.SetFlags(0) + log.SetOutput(&logWriter{}) +} + +type logWriter struct{} + +func (w *logWriter) Write(p []byte) (int, error) { + ts := time.Now().Format("15:04:05") + return fmt.Fprintf(os.Stderr, "%s %s", ts, p) +} + +func logInfo(tag, format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + log.Printf("[%-8s] %s", tag, msg) +} + +// ═══════════════════════════════════════════════ +// 版本 +// ═══════════════════════════════════════════════ + +var ( + Version = "dev" + BuildTime = "unknown" +) + +// ═══════════════════════════════════════════════ +// 路径工具 +// ═══════════════════════════════════════════════ + +func exeDir() string { + p, err := os.Executable() + if err != nil { + return "." + } + return filepath.Dir(p) +} + +func resolvePath(baseDir, p string) string { + if filepath.IsAbs(p) { + return p + } + return filepath.Join(baseDir, p) +} + +// ═══════════════════════════════════════════════ +// 配置结构 +// ═══════════════════════════════════════════════ + +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"` +} + +func defaultConfig() ServerConfig { + return ServerConfig{ + Listen: ":8080", + DevicesFile: "data/devices.yaml", + Auth: AuthConfig{MinerID: "admin", Password: "123456"}, + MinerAPIBase: "https://pool.kryptex.com/prl/api/v3/miner/workers", + FetchInterval: 30, + HistoryMax: 200, + } +} + +// ═══════════════════════════════════════════════ +// 配置加载(不存在则自动生成) +// ═══════════════════════════════════════════════ + +// ═══════════════════════════════════════════════ +// 路径工具 +// ═══════════════════════════════════════════════ + +// baseDir: 优先工作目录,其次 exe 目录 +func baseDir() string { + // 当前工作目录 + if wd, err := os.Getwd(); err == nil { + return wd + } + // exe 目录(打包后使用) + if p, err := os.Executable(); err == nil { + return filepath.Dir(p) + } + return "." +} + +// ═══════════════════════════════════════════════ +// 配置加载(不存在则自动生成) +// ═══════════════════════════════════════════════ + +// 搜索顺序: explicit > 工作目录 > exe 目录 +func findConfigPaths(explicit string) []string { + var paths []string + if explicit != "" { + paths = append(paths, explicit) + } + // 工作目录(go run 时 = 项目目录) + if wd, err := os.Getwd(); err == nil { + paths = append(paths, filepath.Join(wd, "server.yaml")) + } + // exe 目录(打包分发时 = exe 所在目录) + if p, err := os.Executable(); err == nil { + d := filepath.Dir(p) + if wd, _ := os.Getwd(); d != wd { + paths = append(paths, filepath.Join(d, "server.yaml")) + } + } + return paths +} + +func loadOrCreateConfig(explicitPath string) (ServerConfig, string) { + cfg := defaultConfig() + paths := findConfigPaths(explicitPath) + + // 1. 尝试找到已有配置文件 + for _, p := range paths { + abs, _ := filepath.Abs(p) + raw, err := os.ReadFile(abs) + if err != nil { + continue + } + if err := yaml.Unmarshal(raw, &cfg); err != nil { + logInfo("config", "解析 %s 失败: %v", abs, err) + continue + } + fillDefaults(&cfg) + logInfo("config", "已加载: %s", abs) + logInfo("config", "listen=%s devices=%s", cfg.Listen, cfg.DevicesFile) + return cfg, abs + } + + // 2. 全部不存在 → 在第一个搜索路径生成默认配置 + genPath, _ := filepath.Abs(paths[0]) + fillDefaults(&cfg) + out, _ := yaml.Marshal(&cfg) + hdr := "# Hardware Monitor Server 配置\n#\n" + hdr += "# listen : 监听地址\n" + hdr += "# devices_file : 设备注册表路径 (相对于配置文件所在目录)\n" + hdr += "#\n" + hdr += "# auth:\n" + hdr += "# miner_id : 登录设备ID\n" + hdr += "# password : 登录密码 (明文)\n" + hdr += "#\n" + hdr += "# miner_uid : Kryptex 钱包地址\n" + hdr += "# miner_api_base: 矿池 API 地址\n" + hdr += "# fetch_interval: 拉取间隔 (秒)\n" + hdr += "# history_max : 算力历史最大条数\n" + hdr += "#\n" + hdr += "# 删除此文件可恢复默认配置\n\n" + dir := filepath.Dir(genPath) + os.MkdirAll(dir, 0755) + os.WriteFile(genPath, append([]byte(hdr), out...), 0644) + logInfo("config", "已生成默认配置: %s", genPath) + return cfg, genPath +} + +func fillDefaults(cfg *ServerConfig) { + if cfg.Listen == "" { + cfg.Listen = ":8080" + } + 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.MinerAPIBase == "" { + cfg.MinerAPIBase = "https://pool.kryptex.com/prl/api/v3/miner/workers" + } + if cfg.FetchInterval <= 0 { + cfg.FetchInterval = 30 + } + if cfg.HistoryMax <= 0 { + cfg.HistoryMax = 200 + } +} + +// ═══════════════════════════════════════════════ +// 设备注册表 +// ═══════════════════════════════════════════════ + +type DeviceFile struct { + Devices map[string]*DeviceRecord `yaml:"devices"` +} + +type DeviceRecord struct { + Name string `yaml:"machine_name" json:"machine_name"` + FirstSeen time.Time `yaml:"first_seen" json:"first_seen"` + LastSeen time.Time `yaml:"last_seen" json:"last_seen"` + Board BoardInfo `yaml:"motherboard" json:"motherboard"` + CPUModel string `yaml:"cpu_model" json:"cpu_model"` + CPUCoresP int `yaml:"cpu_cores_physical" json:"cpu_cores_physical"` + CPUCoresL int `yaml:"cpu_cores_logical" json:"cpu_cores_logical"` + GPUs []GPURec `yaml:"gpus" json:"gpus"` +} + +type BoardInfo struct { + Manufacturer string `yaml:"manufacturer,omitempty" json:"manufacturer,omitempty"` + Product string `yaml:"product,omitempty" json:"product,omitempty"` +} + +type GPURec struct { + Name string `yaml:"name" json:"name"` + VRAM float64 `yaml:"vram_total_mb" json:"vram_total_mb"` +} + +// ═══════════════════════════════════════════════ +// 矿池数据 +// ═══════════════════════════════════════════════ + +type MiningData struct { + Status string `json:"status"` + Coin string `json:"coin"` + Valid int `json:"valid"` + Stale int `json:"stale"` + Invalid int `json:"invalid"` + Hashrate30m float64 `json:"hashrate_30m"` + Hashrate3h float64 `json:"hashrate_3h"` + Hashrate24h float64 `json:"hashrate_24h"` + Agent string `json:"agent"` + Country string `json:"country"` + LastActive int64 `json:"last_active"` + OpenedAt int64 `json:"opened_at"` + FetchedAt time.Time `json:"fetched_at"` +} + +type HashratePoint struct { + T string `json:"t"` + V float64 `json:"v"` +} + +// ═══════════════════════════════════════════════ +// 消息结构 +// ═══════════════════════════════════════════════ + +type AgentPayload struct { + MachineName string `json:"machine_name"` + Motherboard struct { + Manufacturer string `json:"manufacturer"` + Product string `json:"product"` + } `json:"motherboard"` + CPU struct { + Model string `json:"model"` + CoresPhysical int `json:"cores_physical"` + CoresLogical int `json:"cores_logical"` + } `json:"cpu"` + GPUs []struct { + Name string `json:"name"` + VRAM float64 `json:"vram_total_mb"` + } `json:"gpus"` +} + +type BrowserDevice struct { + Info *DeviceRecord `json:"info"` + Live json.RawMessage `json:"live,omitempty"` + Online bool `json:"online"` + Mining *MiningData `json:"mining,omitempty"` + History []HashratePoint `json:"hash_history,omitempty"` +} + +type BrowserMsg struct { + Type string `json:"type"` + Msg string `json:"msg,omitempty"` + Machine string `json:"machine,omitempty"` + Device *BrowserDevice `json:"device,omitempty"` + Mining *MiningData `json:"mining,omitempty"` + History []HashratePoint `json:"hash_history,omitempty"` + Devices map[string]*BrowserDevice `json:"devices,omitempty"` +} + +type BrowserCmd struct { + Type string `json:"type"` + Action string `json:"action"` + Machine string `json:"machine"` + MinerID string `json:"miner_id"` + Password string `json:"password"` +} + +// ═══════════════════════════════════════════════ +// Hub +// ═══════════════════════════════════════════════ + +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 +} + +func NewHub(cfg ServerConfig, devicesAbs string) *Hub { + h := &Hub{ + records: make(map[string]*DeviceRecord), + live: make(map[string]json.RawMessage), + monitors: make(map[*websocket.Conn]struct{}), + miningData: make(map[string]*MiningData), + hashHist: make(map[string][]HashratePoint), + devicesAbs: devicesAbs, + config: cfg, + } + h.loadYAML() + return h +} + +func (h *Hub) loadYAML() { + data, err := os.ReadFile(h.devicesAbs) + if err != nil { + return + } + var f DeviceFile + if yaml.Unmarshal(data, &f) != nil || f.Devices == nil { + return + } + h.records = f.Devices + logInfo("yaml", "加载 %d 台设备", len(h.records)) + for name, r := range h.records { + log.Printf(" · %-20s CPU: %-36s %s", name, r.CPUModel, r.LastSeen.Format("01-02 15:04")) + } +} + +func (h *Hub) saveYAML() { + h.mu.RLock() + snap := make(map[string]*DeviceRecord, len(h.records)) + for k, v := range h.records { + c := *v + snap[k] = &c + } + h.mu.RUnlock() + + out, err := yaml.Marshal(&DeviceFile{Devices: snap}) + if err != nil { + return + } + hdr := fmt.Sprintf("# Hardware Monitor — 设备注册表\n# 更新: %s\n\n", time.Now().Format("2006-01-02 15:04:05")) + dir := filepath.Dir(h.devicesAbs) + os.MkdirAll(dir, 0755) + h.fileMu.Lock() + defer h.fileMu.Unlock() + os.WriteFile(h.devicesAbs, append([]byte(hdr), out...), 0644) +} + +func (h *Hub) UpdateDevice(raw json.RawMessage) { + var p AgentPayload + if json.Unmarshal(raw, &p) != nil || p.MachineName == "" { + return + } + name := p.MachineName + now := time.Now() + + h.mu.Lock() + rec, existed := h.records[name] + if !existed { + rec = &DeviceRecord{Name: name, FirstSeen: now} + h.records[name] = rec + } + rec.LastSeen = now + rec.Board = BoardInfo{Manufacturer: p.Motherboard.Manufacturer, Product: p.Motherboard.Product} + rec.CPUModel = p.CPU.Model + rec.CPUCoresP = p.CPU.CoresPhysical + rec.CPUCoresL = p.CPU.CoresLogical + rec.GPUs = make([]GPURec, 0, len(p.GPUs)) + for _, g := range p.GPUs { + rec.GPUs = append(rec.GPUs, GPURec{Name: g.Name, VRAM: g.VRAM}) + } + cp := *rec + h.live[name] = raw + var md *MiningData + var hist []HashratePoint + if m, ok := h.miningData[name]; ok { + md = m + } + if hs, ok := h.hashHist[name]; ok { + hist = make([]HashratePoint, len(hs)) + copy(hist, hs) + } + h.mu.Unlock() + + if !existed { + logInfo("new", "%s CPU=%s", name, cp.CPUModel) + } + h.saveYAML() + h.broadcast(BrowserMsg{ + Type: "device", Machine: name, + Device: &BrowserDevice{Info: &cp, Live: raw, Online: true, Mining: md, History: hist}, + }) +} + +func (h *Hub) MarkOffline(name string) { + h.mu.Lock() + _, had := h.live[name] + delete(h.live, name) + rec, ok := h.records[name] + var c *DeviceRecord + if ok { + cp := *rec + c = &cp + } + var md *MiningData + var hist []HashratePoint + if m, ok := h.miningData[name]; ok { + md = m + } + if hs, ok := h.hashHist[name]; ok { + hist = make([]HashratePoint, len(hs)) + copy(hist, hs) + } + h.mu.Unlock() + + if had && c != nil { + h.broadcast(BrowserMsg{ + Type: "device", Machine: name, + Device: &BrowserDevice{Info: c, Online: false, 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) AddMonitor(c *websocket.Conn) { + h.mu.Lock() + h.monitors[c] = struct{}{} + h.mu.Unlock() + c.WriteJSON(BrowserMsg{Type: "snapshot", Devices: h.snapshot()}) + logInfo("monitor+", "浏览器连接 (%d 在线)", len(h.monitors)) +} + +func (h *Hub) RemoveMonitor(c *websocket.Conn) { + h.mu.Lock() + delete(h.monitors, c) + h.mu.Unlock() + c.Close() + logInfo("monitor-", "浏览器断开 (%d 在线)", len(h.monitors)) +} + +func (h *Hub) snapshot() map[string]*BrowserDevice { + h.mu.RLock() + defer h.mu.RUnlock() + out := make(map[string]*BrowserDevice, len(h.records)) + for name, rec := range h.records { + bd := &BrowserDevice{Info: rec, Online: false} + if live, ok := h.live[name]; ok { + bd.Live = live + bd.Online = true + } + if md, ok := h.miningData[name]; ok { + bd.Mining = md + if hs, ok := h.hashHist[name]; ok { + bd.History = make([]HashratePoint, len(hs)) + copy(bd.History, hs) + } + } + out[name] = bd + } + return out +} + +func (h *Hub) broadcast(msg BrowserMsg) { + data, _ := json.Marshal(msg) + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.monitors { + if err := c.WriteMessage(websocket.TextMessage, data); err != nil { + go h.RemoveMonitor(c) + } + } +} + +func (h *Hub) CleanStale() { + tick := time.NewTicker(10 * time.Second) + defer tick.Stop() + for range tick.C { + h.mu.RLock() + var stale []string + for name, rec := range h.records { + if _, online := h.live[name]; online && time.Since(rec.LastSeen) > 20*time.Second { + stale = append(stale, name) + } + } + h.mu.RUnlock() + for _, name := range stale { + h.MarkOffline(name) + logInfo("offline", "%s", name) + } + } +} + +// ═══════════════════════════════════════════════ +// 矿池拉取 +// ═══════════════════════════════════════════════ + +func (h *Hub) StartMinerFetcher() { + if h.config.MinerUID == "" { + logInfo("miner", "未配置 miner_uid,跳过") + return + } + url := h.config.MinerAPIBase + "/" + h.config.MinerUID + interval := time.Duration(h.config.FetchInterval) * time.Second + logInfo("miner", "启动 (每 %v)", interval) + + h.fetchMinerData(url) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + h.mu.RLock() + n := len(h.monitors) + h.mu.RUnlock() + if n > 0 { + h.fetchMinerData(url) + } + } +} + +func (h *Hub) fetchMinerData(url string) { + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(url) + if err != nil { + logInfo("miner", "请求失败: %v", err) + return + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + logInfo("miner", "HTTP %d", resp.StatusCode) + return + } + var apiResp struct { + Results []struct { + Worker string `json:"worker"` + Status string `json:"status"` + Coin string `json:"coin"` + Valid int `json:"valid"` + Stale int `json:"stale"` + Invalid int `json:"invalid"` + AvgHashrate24h string `json:"avg_hashrate_24h"` + AvgHashrate3h string `json:"avg_hashrate_3h"` + AvgHashrate30m string `json:"avg_hashrate_30m"` + Agent string `json:"agent"` + Country string `json:"country"` + LastActive int64 `json:"last_active"` + OpenedAt int64 `json:"opened_at"` // ★ 新增 + } `json:"results"` + } + if json.NewDecoder(resp.Body).Decode(&apiResp) != nil { + return + } + + now := time.Now() + h.mu.Lock() + for _, w := range apiResp.Results { + hr30m, _ := strconv.ParseFloat(w.AvgHashrate30m, 64) + hr3h, _ := strconv.ParseFloat(w.AvgHashrate3h, 64) + hr24h, _ := strconv.ParseFloat(w.AvgHashrate24h, 64) + h.miningData[w.Worker] = &MiningData{ + Status: w.Status, Coin: w.Coin, Valid: w.Valid, Stale: w.Stale, Invalid: w.Invalid, + Hashrate30m: hr30m, Hashrate3h: hr3h, Hashrate24h: hr24h, + Agent: w.Agent, Country: w.Country, + LastActive: w.LastActive, OpenedAt: w.OpenedAt, // ★ 加 OpenedAt + FetchedAt: now, + } + h.hashHist[w.Worker] = append(h.hashHist[w.Worker], HashratePoint{T: now.Format(time.RFC3339), V: hr30m}) + if len(h.hashHist[w.Worker]) > h.config.HistoryMax { + h.hashHist[w.Worker] = h.hashHist[w.Worker][len(h.hashHist[w.Worker])-h.config.HistoryMax:] + } + } + h.mu.Unlock() + + for _, w := range apiResp.Results { + h.mu.RLock() + md := h.miningData[w.Worker] + hist := make([]HashratePoint, len(h.hashHist[w.Worker])) + copy(hist, h.hashHist[w.Worker]) + h.mu.RUnlock() + h.broadcast(BrowserMsg{Type: "mining", Machine: w.Worker, Mining: md, History: hist}) + } + logInfo("miner", "更新 %d 个 worker", len(apiResp.Results)) +} + +// ═══════════════════════════════════════════════ +// WebSocket +// ═══════════════════════════════════════════════ + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + ReadBufferSize: 65536, + WriteBufferSize: 262144, +} + +func handleAgent(conn *websocket.Conn, hub *Hub) { + defer conn.Close() + addr := conn.RemoteAddr().String() + logInfo("agent+", "%s", addr) + defer logInfo("agent-", "%s", addr) + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + hub.UpdateDevice(raw) + } +} + +func handleMonitor(conn *websocket.Conn, hub *Hub) { + addr := conn.RemoteAddr().String() + defer conn.Close() + + // ① 要求认证 + conn.WriteJSON(BrowserMsg{Type: "auth_required"}) + + // ② 等待认证 (15 秒超时) + conn.SetReadDeadline(time.Now().Add(15 * time.Second)) + + var authOK bool + for { + _, raw, err := conn.ReadMessage() + if err != nil { + logInfo("monitor", "认证超时: %s", addr) + return + } + + var cmd BrowserCmd + if json.Unmarshal(raw, &cmd) != nil { + continue + } + if cmd.Type != "auth" { + continue + } + + // 校验 + if cmd.MinerID == hub.config.Auth.MinerID && cmd.Password == hub.config.Auth.Password { + authOK = true + break + } + + logInfo("monitor", "认证失败: %s", addr) + conn.WriteJSON(BrowserMsg{Type: "auth_fail", Msg: "设备ID或密码错误"}) + } + + if !authOK { + return + } + + // ③ 认证通过 + conn.SetReadDeadline(time.Time{}) + conn.WriteJSON(BrowserMsg{Type: "auth_ok"}) + logInfo("monitor+", "认证通过: %s", addr) + + // ④ 进入正常数据流 + hub.AddMonitor(conn) + defer hub.RemoveMonitor(conn) + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + var cmd BrowserCmd + if json.Unmarshal(raw, &cmd) == nil && cmd.Action == "delete" && cmd.Machine != "" { + hub.DeleteDevice(cmd.Machine) + } + } +} + +// ═══════════════════════════════════════════════ +// HTML +// ═══════════════════════════════════════════════ + +//go:embed templates/index.html +var indexHTML string + +// ═══════════════════════════════════════════════ +// main +// ═══════════════════════════════════════════════ + +func main() { + showVersion := flag.Bool("version", false, "显示版本信息") + configFlag := flag.String("config", "", "配置文件路径 (默认: 当前目录 server.yaml)") + flag.Parse() + + if *showVersion { + fmt.Printf("Hardware Monitor Server %s\n", Version) + fmt.Printf("Build: %s / %s\n", BuildTime, runtime.GOOS+"/"+runtime.GOARCH) + return + } + + base := baseDir() // ★ 改这里 + + cfgPath := *configFlag + if cfgPath == "" { + cfgPath = filepath.Join(base, "server.yaml") + } + cfg, cfgAbs := loadOrCreateConfig(cfgPath) + + devicesAbs := resolvePath(filepath.Dir(cfgAbs), cfg.DevicesFile) + os.MkdirAll(filepath.Dir(devicesAbs), 0755) + + hub := NewHub(cfg, devicesAbs) + go hub.CleanStale() + go hub.StartMinerFetcher() + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, indexHTML) + }) + mux.HandleFunc("/ws/agent", func(w http.ResponseWriter, r *http.Request) { + if c, err := upgrader.Upgrade(w, r, nil); err == nil { + go handleAgent(c, hub) + } + }) + mux.HandleFunc("/ws/monitor", func(w http.ResponseWriter, r *http.Request) { + if c, err := upgrader.Upgrade(w, r, nil); err == nil { + go handleMonitor(c, hub) + } + }) + + fmt.Println() + fmt.Println(" ╔═══════════════════════════════════════════╗") + fmt.Println(" ║ Hardware Monitor Server ║") + fmt.Printf(" ║ Version %-28s ║\n", Version) + fmt.Println(" ╠═══════════════════════════════════════════╣") + fmt.Printf(" ║ Dashboard : http://localhost%-13s ║\n", cfg.Listen) + fmt.Printf(" ║ Agent WS : ws://localhost%-12s/ws ║\n", cfg.Listen) + if cfgAbs != "" { + fmt.Printf(" ║ Config : %-29s║\n", trunc(cfgAbs, 29)) + } + fmt.Printf(" ║ Devices : %-29s║\n", trunc(devicesAbs, 29)) + if cfg.MinerUID != "" { + uid := cfg.MinerUID + if len(uid) > 22 { + uid = uid[:22] + "..." + } + fmt.Printf(" ║ Miner : %-29s║\n", uid) + } else { + fmt.Println(" ║ Miner : 未配置 ║") + } + fmt.Println(" ╚═══════════════════════════════════════════╝") + fmt.Println() + + log.Fatal(http.ListenAndServe(cfg.Listen, mux)) +} + +func trunc(s string, n int) string { + if len(s) <= n { + return s + } + return "..." + s[len(s)-n+3:] +} diff --git a/cmd/server/server.yaml b/cmd/server/server.yaml new file mode 100644 index 0000000..08be776 --- /dev/null +++ b/cmd/server/server.yaml @@ -0,0 +1,21 @@ +# 监听地址 +listen: ":8080" + +# 设备注册文件 +devices_file: "data/devices.yaml" +# ─── 登录认证 ─── +auth: + miner_id: "admin" + password: "123456" +# ─── 矿池配置 ─── +# Kryptex 矿池 UID (钱包地址) +miner_uid: "prl1prkg7des097jx9lw22kusw75s0uy0xfmxgmr7l7r67csmpxzad4lqsaajgt" + +# API 基础地址 (完整地址 = base + "/" + uid) +miner_api_base: "https://pool.kryptex.com/prl/api/v3/miner/workers" + +# 拉取间隔 (秒) +fetch_interval: 5 + +# 算力历史最大条数 (200 × 30s ≈ 100 分钟) +history_max: 200 \ No newline at end of file diff --git a/cmd/server/templates/index.html b/cmd/server/templates/index.html new file mode 100644 index 0000000..bfa69a8 --- /dev/null +++ b/cmd/server/templates/index.html @@ -0,0 +1,1578 @@ + + + + + + Hardware Monitor + + + + + + + +
+ +
+ + + + + +
+ + + + \ No newline at end of file