package server import ( "net/http" "strconv" "strings" "sync" "time" ) // hostStaticInfo describes hardware identities that do not change while the // process runs, so they are probed once and cached. type hostStaticInfo struct { CPUModel string `json:"cpu_model"` BoardModel string `json:"board_model"` MemoryModel string `json:"memory_model"` DiskModel string `json:"disk_model"` } // hostPerfSnapshot is one rendered read of host utilization for the dashboard. type hostPerfSnapshot struct { CPUPercent float64 `json:"cpu_percent"` MemoryPercent float64 `json:"memory_percent"` MemoryUsed uint64 `json:"memory_used_bytes"` MemoryTotal uint64 `json:"memory_total_bytes"` DiskPercent float64 `json:"disk_percent"` DiskUsed uint64 `json:"disk_used_bytes"` DiskTotal uint64 `json:"disk_total_bytes"` NetRxBps float64 `json:"net_rx_bps"` NetTxBps float64 `json:"net_tx_bps"` } // hostCPUTimes is one cumulative /proc/stat reading: idle already includes // iowait, total sums every other column (guest time is already folded into // user/nice and therefore excluded). type hostCPUTimes struct { idle uint64 total uint64 } const ( // hostStatsMinGap keeps back-to-back polls from dividing a handful of // jiffies by a few milliseconds; the previous rate is reused instead. hostStatsMinGap = 300 * time.Millisecond // hostStatsMaxGap mirrors liveNetMaxGap: a gap past this means the tab was // closed or the browser was hidden; re-baseline instead of averaging a // long dead interval. hostStatsMaxGap = 15 * time.Second // hostStatsFirstSample is how long the very first request blocks so CPU // and network readings have a real interval to average over. It must // exceed hostStatsMinGap so the re-read survives the min-gap guard below. hostStatsFirstSample = 400 * time.Millisecond ) // hostStatsSampler derives live host utilization from cumulative kernel // counters. Like liveNetTracker it is driven on demand by dashboard polling, // so no background goroutine is required. type hostStatsSampler struct { mu sync.Mutex static *hostStaticInfo sampledAt time.Time prevCPU hostCPUTimes prevNetRx uint64 prevNetTx uint64 lastCPU float64 lastRxBps float64 lastTxBps float64 } func newHostStatsSampler() *hostStatsSampler { return &hostStatsSampler{} } // handleDashboardHost serves the dashboard host card: static hardware identity // plus live utilization. Both halves are cheap reads of /proc and /sys. func (s *Server) handleDashboardHost(w http.ResponseWriter, r *http.Request) { if !requireMethod(w, r, http.MethodGet) { return } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ "host": s.hostStats.info(), "perf": s.hostStats.perf(), }}) } // info returns the cached static hardware description, probing it on first use. func (s *hostStatsSampler) info() hostStaticInfo { s.mu.Lock() defer s.mu.Unlock() if s.static == nil { info := probeHostStatic() s.static = &info } return *s.static } // perf renders one utilization snapshot. CPU and network rates need a baseline, // so the first-ever call takes a short inline second reading; later calls // average against the previous request. func (s *hostStatsSampler) perf() hostPerfSnapshot { s.mu.Lock() defer s.mu.Unlock() now := time.Now() cpu, cpuOK := readHostCPUTimes() rx, tx, netOK := readHostNetTotals() // No usable baseline yet (first request, or the tab was hidden past the // max gap): establish one, then re-read after a short interval so the // first dashboard paint already reports real numbers. needBaseline := s.sampledAt.IsZero() || now.Sub(s.sampledAt) > hostStatsMaxGap if needBaseline && (cpuOK || netOK) { s.sampledAt = now if cpuOK { s.prevCPU = cpu } if netOK { s.prevNetRx, s.prevNetTx = rx, tx } time.Sleep(hostStatsFirstSample) now = time.Now() if next, ok := readHostCPUTimes(); ok { cpu, cpuOK = next, true } if nextRx, nextTx, ok := readHostNetTotals(); ok { rx, tx, netOK = nextRx, nextTx, true } } memPercent, memUsed, memTotal := readHostMemory() diskPercent, diskUsed, diskTotal := readHostDisk() gap := now.Sub(s.sampledAt) if gap >= hostStatsMinGap && (cpuOK || netOK) { if cpuOK { if busyDelta, totalDelta := cpuDelta(s.prevCPU, cpu); totalDelta > 0 { s.lastCPU = clampPercent(float64(busyDelta) * 100 / float64(totalDelta)) } s.prevCPU = cpu } if netOK { // Counter resets (interface flap) must not produce a giant spike. if rx >= s.prevNetRx { s.lastRxBps = float64(rx-s.prevNetRx) / gap.Seconds() } else { s.lastRxBps = 0 } if tx >= s.prevNetTx { s.lastTxBps = float64(tx-s.prevNetTx) / gap.Seconds() } else { s.lastTxBps = 0 } s.prevNetRx, s.prevNetTx = rx, tx } s.sampledAt = now } return hostPerfSnapshot{ CPUPercent: s.lastCPU, MemoryPercent: memPercent, MemoryUsed: memUsed, MemoryTotal: memTotal, DiskPercent: diskPercent, DiskUsed: diskUsed, DiskTotal: diskTotal, NetRxBps: s.lastRxBps, NetTxBps: s.lastTxBps, } } // cpuDelta returns the busy and total jiffies elapsed between two cumulative // readings. A backwards counter (theoretically impossible for /proc/stat) // reports zero rather than wrapping. func cpuDelta(prev, next hostCPUTimes) (busy, total uint64) { if next.total <= prev.total || next.idle < prev.idle { return 0, 0 } totalDelta := next.total - prev.total idleDelta := next.idle - prev.idle if idleDelta >= totalDelta { return 0, totalDelta } return totalDelta - idleDelta, totalDelta } func clampPercent(value float64) float64 { switch { case value < 0: return 0 case value > 100: return 100 default: return value } } // hostNetIgnoredPrefixes are virtual interface name prefixes whose counters // would double-count physical traffic (bridges, tunnels, vocat's own links) or // carry no real host traffic at all. var hostNetIgnoredPrefixes = []string{ "lo", "br-", "docker", "veth", "virbr", "vmnet", "vboxnet", "ip6tnl", "ip6gre", "sit", "gre", "gretap", "erspan", "tun", "tap", "utun", "vocat", "wg", "zt", "tailscale", "ifb", "bond", "vlan", "macvlan", "dummy", "lxc", "cali", "flannel", "cni", } // hostNetInterfaceCounted reports whether an interface's byte counters feed the // host-level upload/download rates. func hostNetInterfaceCounted(name string) bool { name = strings.TrimSpace(name) if name == "" { return false } for _, prefix := range hostNetIgnoredPrefixes { if strings.HasPrefix(name, prefix) { return false } } return true } // parseNetDevCounters sums rx/tx bytes across counted interfaces in // /proc/net/dev content. func parseNetDevCounters(content string) (rx, tx uint64) { for _, line := range strings.Split(content, "\n") { name, rest, found := strings.Cut(line, ":") if !found || !hostNetInterfaceCounted(name) { continue } fields := strings.Fields(rest) if len(fields) < 9 { continue } rxBytes, okRx := parseUint(fields[0]) txBytes, okTx := parseUint(fields[8]) if !okRx || !okTx { continue } rx += rxBytes tx += txBytes } return rx, tx } func parseUint(text string) (uint64, bool) { value, err := strconv.ParseUint(strings.TrimSpace(text), 10, 64) return value, err == nil } // parseCPUTimes parses the aggregate "cpu" line of /proc/stat. func parseCPUTimes(line string) (hostCPUTimes, bool) { fields := strings.Fields(line) // cpu user nice system idle iowait irq softirq steal [guest guest_nice] if len(fields) < 9 || fields[0] != "cpu" { return hostCPUTimes{}, false } var times hostCPUTimes for index, field := range fields[1:9] { value, ok := parseUint(field) if !ok { return hostCPUTimes{}, false } times.total += value if index == 3 || index == 4 { // idle + iowait times.idle += value } } return times, true } // parseMeminfo extracts MemTotal and MemAvailable (bytes) from /proc/meminfo. func parseMeminfo(content string) (total, available uint64, ok bool) { for _, line := range strings.Split(content, "\n") { key, rest, found := strings.Cut(line, ":") if !found { continue } var value uint64 switch strings.TrimSpace(key) { case "MemTotal": value, ok = parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB"))) if ok { total = value * 1024 } case "MemAvailable": if value, parsed := parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB"))); parsed { available = value * 1024 } } } return total, available, total > 0 } // parseCPUInfoModel returns the x86-style "model name" from /proc/cpuinfo, or // an empty string on ARM hosts that only carry CPU part numbers. func parseCPUInfoModel(content string) string { for _, line := range strings.Split(content, "\n") { key, value, found := strings.Cut(line, ":") if !found { continue } switch strings.TrimSpace(key) { case "model name", "Model", "Hardware": if model := strings.TrimSpace(value); model != "" { return model } } } return "" } // parseCPUInfoPart returns the first ARM "CPU part" hex identifier (e.g. // 0xd03) and the number of processors listed. func parseCPUInfoPart(content string) (part string, processors int) { for _, line := range strings.Split(content, "\n") { key, value, found := strings.Cut(line, ":") if !found { continue } switch strings.TrimSpace(key) { case "processor": processors++ case "CPU part": if part == "" { part = strings.ToLower(strings.TrimSpace(value)) } } } return part, processors } // armCPUPartNames maps ARM CPU part identifiers to marketing core names. var armCPUPartNames = map[string]string{ "0xd03": "Cortex-A53", "0xd04": "Cortex-A35", "0xd05": "Cortex-A55", "0xd06": "Cortex-A65", "0xd07": "Cortex-A57", "0xd08": "Cortex-A72", "0xd09": "Cortex-A73", "0xd0a": "Cortex-A75", "0xd0b": "Cortex-A76", "0xd0c": "Neoverse-N1", "0xd0d": "Cortex-A77", "0xd0e": "Cortex-A76AE", "0xd40": "Neoverse-V1", "0xd41": "Cortex-A78", "0xd42": "Cortex-A78AE", "0xd44": "Cortex-X1", "0xd46": "Cortex-A510", "0xd47": "Cortex-A710", "0xd48": "Cortex-X2", "0xd4b": "Cortex-A715", "0xd4d": "Cortex-A520", "0xd4e": "Cortex-X3", } // socVendorNames prettifies the vendor half of a device-tree compatible entry. var socVendorNames = map[string]string{ "allwinner": "Allwinner", "amlogic": "Amlogic", "broadcom": "Broadcom", "mediatek": "MediaTek", "nvidia": "NVIDIA", "qualcomm": "Qualcomm", "raspberrypi": "Raspberry Pi", "rockchip": "Rockchip", "samsung": "Samsung", "ti": "TI", "xunlong": "Xunlong", } // parseCompatibleSoC extracts the SoC half of a device-tree compatible list // (NUL-separated, most specific first): "xunlong,orangepi-zero3\0allwinner, // sun50i-h618\0" yields "Allwinner sun50i-h618". func parseCompatibleSoC(raw string) string { entries := strings.FieldsFunc(raw, func(r rune) bool { return r == 0 || r == '\n' }) // The last entry is the least specific compatible, which on ARM boards is // the SoC rather than the board. for index := len(entries) - 1; index >= 0; index-- { entry := strings.TrimSpace(entries[index]) vendor, soc, found := strings.Cut(entry, ",") if !found || soc == "" { continue } if pretty, ok := socVendorNames[strings.ToLower(vendor)]; ok { vendor = pretty } else { vendor = strings.ToUpper(vendor[:1]) + vendor[1:] } return vendor + " " + soc } return "" } // composeARMCPUModel renders e.g. "Allwinner sun50i-h618 · 4× Cortex-A53". func composeARMCPUModel(soc, part string, processors int) string { core := armCPUPartNames[part] var result string switch { case soc != "" && core != "" && processors > 0: result = soc + " · " + strconv.Itoa(processors) + "× " + core case soc != "" && processors > 0: result = soc + " · " + strconv.Itoa(processors) + "× CPU" case soc != "" && core != "": result = soc + " · " + core default: result = soc } return result } // skipHostDisk reports whether a /sys/block entry is a virtual device whose // "model" would only clutter the host card. func skipHostDisk(name string) bool { for _, prefix := range []string{"loop", "ram", "zram", "sr", "nbd", "dm-", "md", "mtdblock", "ubi", "ubiblock"} { if strings.HasPrefix(name, prefix) { return true } } return false } // parseDmidecodeMemory extracts a compact "8 GB DDR4 M471A1K43CB1-CRC" style // description from `dmidecode -t 17` output, preferring the first populated // slot. Empty when no installed module can be described. func parseDmidecodeMemory(output string) string { var size, memType, partNumber string flush := func() string { if size != "" && partNumber != "" { return strings.TrimSpace(size + " " + memType + " " + partNumber) } if size != "" && memType != "" { return strings.TrimSpace(size + " " + memType) } return "" } for _, line := range strings.Split(output, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(line, "Memory Device") { if composed := flush(); composed != "" { return composed } size, memType, partNumber = "", "", "" continue } key, value, found := strings.Cut(trimmed, ":") if !found { continue } value = strings.TrimSpace(value) switch strings.TrimSpace(key) { case "Size": if !strings.Contains(value, "No Module") && value != "" && value != "Unknown" { size = value } case "Type": if value != "Unknown" && value != "Other" && !strings.HasPrefix(value, "