Files
VoCat/internal/server/host_stats.go
T
MengMengCodeandClaude Opus 4.8 115598757a feat: dashboard host, performance, task, and online-rate cards
Add four cards to the dashboard:

- Host hardware card (CPU / motherboard / memory / disk model) backed by
  a new GET /api/dashboard/host endpoint that probes /proc and /sys once
  and caches the identities. x86 hosts read cpuinfo model name, DMI board
  data, dmidecode DIMM info, and block device models; ARM boards compose
  the device-tree SoC with the Cortex part name and fall back to memory
  capacity.
- Performance card with live CPU / memory / disk usage bars and real-time
  network up/down rates. Rates derive from cumulative kernel counters
  sampled on demand by dashboard polling (no background goroutine), with
  bridge/tunnel/vocat virtual interfaces excluded to avoid double
  counting.
- Upcoming scheduled tasks card listing the next three enabled automatic
  tasks with their run times.
- Module online rate card aggregating all recognized modules into one
  large percentage colored by four levels (red/orange/yellow/green).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 17:46:38 +08:00

469 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "<OUT OF SPEC") {
memType = value
}
case "Part Number":
if value != "Unknown" && value != "None" && value != "" {
partNumber = value
}
}
}
return flush()
}