diff --git a/internal/server/device_api.go b/internal/server/device_api.go index dd12a0f..12b7a36 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -142,6 +142,9 @@ func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool { } writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()}) return true + case "dashboard/host": + s.handleDashboardHost(w, r) + return true case "devices": return s.handleDevices(w, r) case "devices/discovered": diff --git a/internal/server/host_stats.go b/internal/server/host_stats.go new file mode 100644 index 0000000..8424180 --- /dev/null +++ b/internal/server/host_stats.go @@ -0,0 +1,468 @@ +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, " 0 { + percent = clampPercent(float64(used) * 100 / float64(denominator)) + } + return percent, used, total +} + +// readHostNetTotals sums rx/tx counters across physical host interfaces. +func readHostNetTotals() (rx, tx uint64, ok bool) { + raw, err := os.ReadFile("/proc/net/dev") + if err != nil { + return 0, 0, false + } + rx, tx = parseNetDevCounters(string(raw)) + return rx, tx, true +} diff --git a/internal/server/host_stats_other.go b/internal/server/host_stats_other.go new file mode 100644 index 0000000..471ada6 --- /dev/null +++ b/internal/server/host_stats_other.go @@ -0,0 +1,16 @@ +//go:build !linux + +package server + +// Host statistics are only meaningful on the Linux deployment target; on other +// platforms every probe reports empty/zero and the dashboard renders "—". + +func probeHostStatic() hostStaticInfo { return hostStaticInfo{} } + +func readHostCPUTimes() (hostCPUTimes, bool) { return hostCPUTimes{}, false } + +func readHostNetTotals() (uint64, uint64, bool) { return 0, 0, false } + +func readHostMemory() (float64, uint64, uint64) { return 0, 0, 0 } + +func readHostDisk() (float64, uint64, uint64) { return 0, 0, 0 } diff --git a/internal/server/host_stats_test.go b/internal/server/host_stats_test.go new file mode 100644 index 0000000..b8f4d94 --- /dev/null +++ b/internal/server/host_stats_test.go @@ -0,0 +1,184 @@ +package server + +import ( + "testing" +) + +func TestParseCPUTimes(t *testing.T) { + times, ok := parseCPUTimes("cpu 38073 0 24013 6762971 3121 0 3019 0 0 0") + if !ok { + t.Fatal("parseCPUTimes rejected a valid cpu line") + } + wantTotal := uint64(38073 + 0 + 24013 + 6762971 + 3121 + 0 + 3019 + 0) + if times.total != wantTotal { + t.Fatalf("total = %d, want %d", times.total, wantTotal) + } + if wantIdle := uint64(6762971 + 3121); times.idle != wantIdle { + t.Fatalf("idle = %d, want %d", times.idle, wantIdle) + } + if _, ok := parseCPUTimes("cpu0 1 2 3 4 5 6 7 8"); ok { + t.Fatal("per-core line must not parse as the aggregate line") + } + if _, ok := parseCPUTimes("cpu 1 2 3"); ok { + t.Fatal("truncated cpu line must not parse") + } +} + +func TestCPUDelta(t *testing.T) { + prev := hostCPUTimes{idle: 100, total: 200} + next := hostCPUTimes{idle: 150, total: 300} + busy, total := cpuDelta(prev, next) + if busy != 50 || total != 100 { + t.Fatalf("cpuDelta = (%d, %d), want (50, 100)", busy, total) + } + if busy, total := cpuDelta(next, prev); busy != 0 || total != 0 { + t.Fatalf("backwards counters must report zero, got (%d, %d)", busy, total) + } +} + +func TestParseMeminfo(t *testing.T) { + content := "MemTotal: 2040424 kB\nMemFree: 920864 kB\nMemAvailable: 1543480 kB\nBuffers: 315908 kB\n" + total, available, ok := parseMeminfo(content) + if !ok { + t.Fatal("parseMeminfo rejected valid content") + } + if total != 2040424*1024 { + t.Fatalf("total = %d, want %d", total, 2040424*1024) + } + if available != 1543480*1024 { + t.Fatalf("available = %d, want %d", available, 1543480*1024) + } +} + +func TestParseNetDevCounters(t *testing.T) { + content := `Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 10 1 0 0 0 0 0 0 20 2 0 0 0 0 0 0 + eth0: 100 1 0 0 0 0 0 0 200 2 0 0 0 0 0 0 +br-lan: 1000 1 0 0 0 0 0 0 2000 2 0 0 0 0 0 0 + utun: 300 1 0 0 0 0 0 0 400 2 0 0 0 0 0 0 +vocat50a684ceb0: 500 1 0 0 0 0 0 0 600 2 0 0 0 0 0 0 + wwan0: 700 1 0 0 0 0 0 0 800 2 0 0 0 0 0 0 +` + rx, tx := parseNetDevCounters(content) + // Only eth0 and wwan0 count; lo, br-lan, utun and vocat are virtual. + if rx != 800 || tx != 1000 { + t.Fatalf("rx,tx = %d,%d, want 800,1000", rx, tx) + } +} + +func TestHostNetInterfaceCounted(t *testing.T) { + counted := []string{"eth0", "eth1", "wwan0", "usb0", "wlan0", "enp3s0", "pppoe-wan"} + for _, name := range counted { + if !hostNetInterfaceCounted(name) { + t.Fatalf("%s should be counted", name) + } + } + skipped := []string{"lo", "br-lan", "docker0", "veth123", "ip6tnl0", "sit0", "utun", "vocat50a684ceb0", "wg0", "tun0", "tailscale0", ""} + for _, name := range skipped { + if hostNetInterfaceCounted(name) { + t.Fatalf("%s should be skipped", name) + } + } +} + +func TestParseCPUInfoModelX86(t *testing.T) { + content := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz\n" + if model := parseCPUInfoModel(content); model != "Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz" { + t.Fatalf("model = %q", model) + } +} + +func TestParseCPUInfoARM(t *testing.T) { + content := "processor\t: 0\nBogoMIPS\t: 48.00\nCPU implementer\t: 0x41\nCPU part\t: 0xd03\nprocessor\t: 1\nCPU part\t: 0xd03\n" + if model := parseCPUInfoModel(content); model != "" { + t.Fatalf("ARM cpuinfo must not report an x86 model name, got %q", model) + } + part, processors := parseCPUInfoPart(content) + if part != "0xd03" || processors != 2 { + t.Fatalf("part,processors = %q,%d, want 0xd03,2", part, processors) + } +} + +func TestParseCompatibleSoC(t *testing.T) { + raw := "xunlong,orangepi-zero3\x00allwinner,sun50i-h618\x00" + if soc := parseCompatibleSoC(raw); soc != "Allwinner sun50i-h618" { + t.Fatalf("soc = %q", soc) + } + if soc := parseCompatibleSoC(""); soc != "" { + t.Fatalf("empty compatible must yield empty soc, got %q", soc) + } +} + +func TestComposeARMCPUModel(t *testing.T) { + model := composeARMCPUModel("Allwinner sun50i-h618", "0xd03", 4) + if model != "Allwinner sun50i-h618 · 4× Cortex-A53" { + t.Fatalf("model = %q", model) + } + if model := composeARMCPUModel("", "", 0); model != "" { + t.Fatalf("empty inputs must yield empty model, got %q", model) + } +} + +func TestParseDmidecodeMemory(t *testing.T) { + output := `# dmidecode 3.3 +Getting SMBIOS data from sysfs. +SMBIOS 3.0 present. + +Handle 0x0010, DMI type 17, 40 bytes +Memory Device + Array Handle: 0x000F + Error Information Handle: Not Provided + Total Width: 64 bits + Data Width: 64 bits + Size: 8 GB + Form Factor: SODIMM + Type: DDR4 + Speed: 2400 MT/s + Manufacturer: Samsung + Serial Number: 12345678 + Part Number: M471A1K43CB1-CRC + Rank: 1 + +Handle 0x0011, DMI type 17, 40 bytes +Memory Device + Size: No Module Installed + Type: Unknown +` + if model := parseDmidecodeMemory(output); model != "8 GB DDR4 M471A1K43CB1-CRC" { + t.Fatalf("model = %q", model) + } + if model := parseDmidecodeMemory("Memory Device\n\tSize: No Module Installed\n"); model != "" { + t.Fatalf("unpopulated slots must yield empty model, got %q", model) + } +} + +func TestClampPercent(t *testing.T) { + if clampPercent(-1) != 0 || clampPercent(101) != 100 || clampPercent(50) != 50 { + t.Fatal("clampPercent bounds violated") + } +} + +func TestParseUintTrims(t *testing.T) { + if value, ok := parseUint(" 61440000 "); !ok || value != 61440000 { + t.Fatalf("parseUint = %d,%v", value, ok) + } + if _, ok := parseUint("not-a-number"); ok { + t.Fatal("parseUint accepted garbage") + } +} + +func TestSkipHostDiskPrefixes(t *testing.T) { + skipped := []string{"loop0", "ram0", "zram0", "sr0", "nbd0", "dm-0", "md0", "mtdblock0", "ubiblock0_0"} + for _, name := range skipped { + if !skipHostDisk(name) { + t.Fatalf("%s should be skipped", name) + } + } + kept := []string{"sda", "nvme0n1", "mmcblk0", "vda", "sdb"} + for _, name := range kept { + if skipHostDisk(name) { + t.Fatalf("%s should be kept", name) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 6da48bc..b4cbcf3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -82,6 +82,7 @@ type Server struct { updateApplying bool https *httpsmode.Manager netTraffic *liveNetTracker + hostStats *hostStatsSampler publicIPMu sync.RWMutex publicIPs map[string]cachedPublicIP automaticTasks *automaticTaskScheduler @@ -133,6 +134,7 @@ func New(options Options) (*Server, error) { updateToken: strings.TrimSpace(options.UpdateToken), https: options.HTTPS, netTraffic: newLiveNetTracker(), + hostStats: newHostStatsSampler(), publicIPs: make(map[string]cachedPublicIP), updateCheck: update.CheckLatest, updateApply: update.ApplyLatest, diff --git a/web/src/components/dashboard/HostInfoCard.tsx b/web/src/components/dashboard/HostInfoCard.tsx new file mode 100644 index 0000000..249d73c --- /dev/null +++ b/web/src/components/dashboard/HostInfoCard.tsx @@ -0,0 +1,40 @@ +import { ServerRegular } from "@fluentui/react-icons"; +import type { DashboardHostInfo } from "../../types"; +import { useI18n } from "../../lib/i18n"; + +interface Row { + label: string; + value: string; +} + +// 宿主机信息卡:CPU / 主板 / 内存 / 硬盘型号,后端一次性探测后缓存。 +export function HostInfoCard({ info }: { info?: DashboardHostInfo | null }) { + const { t } = useI18n(); + const rows: Row[] = [ + { label: t("CPU 型号"), value: info?.cpuModel || "" }, + { label: t("主板型号"), value: info?.boardModel || "" }, + { label: t("内存型号"), value: info?.memoryModel || "" }, + { label: t("硬盘型号"), value: info?.diskModel || "" }, + ]; + return ( +
+
+ +

{t("宿主机信息")}

+
+
+ {rows.map((row) => ( +
+ {row.label} + + {row.value || "—"} + +
+ ))} +
+
+ ); +} diff --git a/web/src/components/dashboard/HostPerfCard.tsx b/web/src/components/dashboard/HostPerfCard.tsx new file mode 100644 index 0000000..e1d0350 --- /dev/null +++ b/web/src/components/dashboard/HostPerfCard.tsx @@ -0,0 +1,81 @@ +import { ArrowDownRegular, ArrowUpRegular, GaugeRegular } from "@fluentui/react-icons"; +import type { DashboardHostPerf } from "../../types"; +import { useI18n } from "../../lib/i18n"; +import { cx, formatBytes } from "../../lib/utils"; + +function percentText(value: number) { + return `${value.toFixed(1)}%`; +} + +// 利用率越高颜色越危险:<70 绿,<90 黄,其余红。 +function barColor(percent: number) { + if (percent >= 90) return "bg-red-500"; + if (percent >= 70) return "bg-amber-500"; + return "bg-emerald-500"; +} + +function textColor(percent: number) { + if (percent >= 90) return "text-red-600 dark:text-red-400"; + if (percent >= 70) return "text-amber-600 dark:text-amber-400"; + return "text-emerald-600 dark:text-emerald-400"; +} + +function UsageBar({ label, percent, detail }: { label: string; percent: number; detail?: string }) { + const clamped = Math.min(100, Math.max(0, percent || 0)); + return ( +
+
+ {label} + + {detail ? {detail} : null} + {percentText(clamped)} + +
+
+
+
+
+ ); +} + +// 性能信息卡:CPU / 内存 / 硬盘进度条 + 实时网络上下行速率。 +export function HostPerfCard({ perf }: { perf?: DashboardHostPerf | null }) { + const { t } = useI18n(); + return ( +
+
+ +

{t("性能信息")}

+
+
+ + 0 ? `${formatBytes(perf.memoryUsedBytes)} / ${formatBytes(perf.memoryTotalBytes)}` : undefined} + /> + 0 ? `${formatBytes(perf.diskUsedBytes)} / ${formatBytes(perf.diskTotalBytes)}` : undefined} + /> +
+ {t("网络")} + + + + {formatBytes(perf?.netTxBps ?? 0)}/s + + + + {formatBytes(perf?.netRxBps ?? 0)}/s + + +
+
+
+ ); +} diff --git a/web/src/components/dashboard/OnlineRateCard.tsx b/web/src/components/dashboard/OnlineRateCard.tsx new file mode 100644 index 0000000..af930ce --- /dev/null +++ b/web/src/components/dashboard/OnlineRateCard.tsx @@ -0,0 +1,56 @@ +import { PlugConnectedRegular } from "@fluentui/react-icons"; +import { useI18n, tf } from "../../lib/i18n"; +import { cx } from "../../lib/utils"; + +// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。 +type RateLevel = "green" | "yellow" | "orange" | "red"; + +function rateLevel(percent: number): RateLevel { + if (percent >= 100) return "green"; + if (percent >= 80) return "yellow"; + if (percent >= 50) return "orange"; + return "red"; +} + +const LEVEL_STYLES: Record = { + green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" }, + yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" }, + orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" }, + red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" }, +}; + +// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。 +export function OnlineRateCard({ online, total }: { online: number; total: number }) { + const { t } = useI18n(); + const percent = total > 0 ? Math.round((online / total) * 100) : null; + const level = percent === null ? null : rateLevel(percent); + const styles = level ? LEVEL_STYLES[level] : null; + + return ( +
+
+ +

{t("模块在线率")}

+
+
+ {percent === null ? ( +
--%
+ ) : ( +
+ {percent} + % +
+ )} +
+
+ {styles ? ( + + + {t(styles.labelKey)} + + ) : null} + {tf("{online}/{total} 台在线", { online, total })} +
+
+ ); +} diff --git a/web/src/components/dashboard/UpcomingTasksCard.tsx b/web/src/components/dashboard/UpcomingTasksCard.tsx new file mode 100644 index 0000000..bc89205 --- /dev/null +++ b/web/src/components/dashboard/UpcomingTasksCard.tsx @@ -0,0 +1,58 @@ +import { CalendarClockRegular } from "@fluentui/react-icons"; +import { useNavigate } from "react-router-dom"; +import type { DashboardUpcomingTask } from "../../types"; +import { useI18n } from "../../lib/i18n"; + +function formatRunAt(value: string): string { + if (!value || value.startsWith("0001-")) return "--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--"; + return date.toLocaleString(undefined, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +// 将要执行的定时任务卡:按 nextRunAt 升序取前 3 条已启用任务。 +export function UpcomingTasksCard({ tasks }: { tasks: DashboardUpcomingTask[] }) { + const { t } = useI18n(); + const navigate = useNavigate(); + return ( +
+
+
+ +

{t("将要执行的定时任务")}

+
+ +
+ {tasks.length === 0 ? ( +
+ {t("暂无定时任务")} +
+ ) : ( +
+ {tasks.map((task) => ( +
+ + {task.name} + + + {formatRunAt(task.nextRunAt)} + +
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 125e90c..20bb4f2 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -291,6 +291,24 @@ export const EN_DICT: Record = { 重试: "Retry", 暂无设备接入: "No devices connected", 请先在设备管理中添加或接管设备: "Add or adopt a device on the Devices page first", + 宿主机信息: "Host Hardware", + "CPU 型号": "CPU Model", + 主板型号: "Motherboard", + 内存型号: "Memory", + 硬盘型号: "Disk", + 性能信息: "Performance", + "CPU 使用率": "CPU Usage", + 内存使用率: "Memory Usage", + 硬盘使用率: "Disk Usage", + 将要执行的定时任务: "Upcoming Scheduled Tasks", + 查看全部: "View All", + 暂无定时任务: "No scheduled tasks", + 模块在线率: "Module Online Rate", + 优秀: "Excellent", + 良好: "Good", + 一般: "Fair", + 较差: "Poor", + "{online}/{total} 台在线": "{online}/{total} online", // ---- 设置页:通用 ---- 管理网关参数与运行信息: "Manage gateway parameters and runtime info", diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index ae61a53..c492bfc 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useState } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "../api"; -import type { DashboardDevice } from "../types"; +import type { DashboardDevice, DashboardHost, DashboardUpcomingTask } from "../types"; import { usePolling } from "../lib/usePolling"; import { useI18n } from "../lib/i18n"; import { PageHeader } from "../components/ui/PageHeader"; @@ -10,9 +10,17 @@ import { ErrorState } from "../components/ui/ErrorState"; import { ListSkeleton } from "../components/ui/ListSkeleton"; import { EmptyState } from "../components/ui/EmptyState"; import { DeviceCard } from "../components/DeviceCard"; +import { HostInfoCard } from "../components/dashboard/HostInfoCard"; +import { HostPerfCard } from "../components/dashboard/HostPerfCard"; +import { UpcomingTasksCard } from "../components/dashboard/UpcomingTasksCard"; +import { OnlineRateCard } from "../components/dashboard/OnlineRateCard"; interface LoadError { message: string; status?: number; method?: string; url?: string } +// 任务卡只展示最近的三条;定时任务变化慢,轮询间隔比设备/性能数据更宽。 +const UPCOMING_TASK_COUNT = 3; +const TASKS_POLL_INTERVAL = 15000; + export default function DashboardPage() { const { t } = useI18n(); const navigate = useNavigate(); @@ -21,6 +29,8 @@ export default function DashboardPage() { const [devicesError, setDevicesError] = useState(null); const [devicesOkAt, setDevicesOkAt] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); + const [host, setHost] = useState(null); + const [upcomingTasks, setUpcomingTasks] = useState([]); const fetchDevices = useCallback(async () => { setDevicesLoading(true); @@ -38,7 +48,31 @@ export default function DashboardPage() { } }, []); + // 宿主机信息 + 性能数据:2s 轮询让网络速率足够"实时"。 + const fetchHost = useCallback(async () => { + try { + setHost(await api("/dashboard/host")); + } catch { + /* 宿主机数据失败不打断设备监控;保留上一次成功值。 */ + } + }, []); + + const fetchUpcomingTasks = useCallback(async () => { + try { + const data = await api<{ tasks?: DashboardUpcomingTask[] }>("/automatic-tasks"); + const upcoming = (data.tasks || []) + .filter((task) => task.enabled && task.nextRunAt && !task.nextRunAt.startsWith("0001-")) + .sort((a, b) => new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime()) + .slice(0, UPCOMING_TASK_COUNT); + setUpcomingTasks(upcoming); + } catch { + /* 任务列表加载失败时保留旧数据。 */ + } + }, []); + usePolling(fetchDevices, 5000); + usePolling(fetchHost, 2000); + usePolling(fetchUpcomingTasks, TASKS_POLL_INTERVAL); const total = devices.length; const online = devices.filter((d) => d?.healthy).length; @@ -52,6 +86,12 @@ export default function DashboardPage() { subtitle={t("实时监测模组检测状态与出口连通性")} actions={} /> +
+ + + + +
{t("设备总数")}
{total}
{t("在线")}
{online}
diff --git a/web/src/types.ts b/web/src/types.ts index 16c2b7b..af64804 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -144,6 +144,38 @@ export interface DashboardDevice { model?: string; } +export interface DashboardHostInfo { + cpuModel: string; + boardModel: string; + memoryModel: string; + diskModel: string; +} + +export interface DashboardHostPerf { + cpuPercent: number; + memoryPercent: number; + memoryUsedBytes: number; + memoryTotalBytes: number; + diskPercent: number; + diskUsedBytes: number; + diskTotalBytes: number; + netRxBps: number; + netTxBps: number; +} + +export interface DashboardHost { + host: DashboardHostInfo; + perf: DashboardHostPerf; +} + +// 仪表盘定时任务卡只关心名字与下次执行时间。 +export interface DashboardUpcomingTask { + id: number; + name: string; + enabled: boolean; + nextRunAt: string; +} + export interface DeviceOverview extends DeviceListItem { atPort?: string; audioDevice?: string;