//go:build linux package server import ( "context" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "golang.org/x/sys/unix" ) // probeHostStatic gathers the hardware identities shown on the dashboard host // card. Every probe is best-effort: empty fields render as "—" in the SPA. func probeHostStatic() hostStaticInfo { return hostStaticInfo{ CPUModel: readHostCPUModel(), BoardModel: readHostBoardModel(), MemoryModel: readHostMemoryModel(), DiskModel: readHostDiskModel(), } } // readHostCPUModel prefers the x86-style "model name"; on ARM hosts it composes // the device-tree SoC with the core count and Cortex part name. func readHostCPUModel() string { cpuinfo, err := os.ReadFile("/proc/cpuinfo") if err == nil { if model := parseCPUInfoModel(string(cpuinfo)); model != "" { return model } part, processors := parseCPUInfoPart(string(cpuinfo)) if processors == 0 { processors = runtime.NumCPU() } soc := "" if compatible, err := os.ReadFile("/proc/device-tree/compatible"); err == nil { soc = parseCompatibleSoC(string(compatible)) } if model := composeARMCPUModel(soc, part, processors); model != "" { return model } } return runtime.GOARCH } // readHostBoardModel reads the device-tree model on ARM boards and the DMI // board name on x86 machines. func readHostBoardModel() string { if model, err := os.ReadFile("/proc/device-tree/model"); err == nil { if text := strings.TrimSpace(strings.TrimRight(string(model), "\x00")); text != "" { return text } } dmiDir := "/sys/devices/virtual/dmi/id" board := readSysfsTrimmed(filepath.Join(dmiDir, "board_name")) vendor := readSysfsTrimmed(filepath.Join(dmiDir, "board_vendor")) if board != "" && !isPlaceholderDMI(board) { if vendor != "" && !isPlaceholderDMI(vendor) && !strings.Contains(strings.ToLower(board), strings.ToLower(vendor)) { return vendor + " " + board } return board } if product := readSysfsTrimmed(filepath.Join(dmiDir, "product_name")); product != "" && !isPlaceholderDMI(product) { return product } return "" } // isPlaceholderDMI filters the well-known "we never filled this in" DMI // strings so they do not surface as board models. func isPlaceholderDMI(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "", "default string", "to be filled by o.e.m.", "to be filled by o.e.m", "none", "unknown", "n/a", "not specified", "system manufacturer": return true } return false } // readHostMemoryModel reports the installed DIMM description when dmidecode is // available (typical on x86 NAS/PC hosts) and falls back to total capacity, // which is all an ARM board exposes. func readHostMemoryModel() string { if path, err := exec.LookPath("dmidecode"); err == nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if output, err := exec.CommandContext(ctx, path, "-t", "17").Output(); err == nil { if model := parseDmidecodeMemory(string(output)); model != "" { return model } } } if total, _, ok := readHostMemoryBytes(); ok { return formatLiveBytes(float64(total)) } return "" } // readHostDiskModel describes physical block devices, skipping virtual ones // (loop, zram, device-mapper, mtd, optical). Multiple disks join with "; ". func readHostDiskModel() string { entries, err := os.ReadDir("/sys/block") if err != nil { return "" } var disks []string for _, entry := range entries { name := entry.Name() if skipHostDisk(name) { continue } base := filepath.Join("/sys/block", name) sizeText := readSysfsTrimmed(filepath.Join(base, "size")) sectors, ok := parseUint(sizeText) if !ok || sectors == 0 { // An empty card reader reports size 0 and tells us nothing. continue } model := readSysfsTrimmed(filepath.Join(base, "device", "model")) if model == "" { // MMC/SD cards carry the product name instead of a model string. model = readSysfsTrimmed(filepath.Join(base, "device", "name")) } if model == "" { model = name } capacity := formatLiveBytes(float64(sectors) * 512) disks = append(disks, model+" · "+capacity) } return strings.Join(disks, "; ") } func readSysfsTrimmed(path string) string { raw, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(strings.TrimRight(string(raw), "\x00")) } // readHostCPUTimes reads the aggregate counters from /proc/stat. func readHostCPUTimes() (hostCPUTimes, bool) { raw, err := os.ReadFile("/proc/stat") if err != nil { return hostCPUTimes{}, false } for _, line := range strings.Split(string(raw), "\n") { if strings.HasPrefix(line, "cpu ") { return parseCPUTimes(line) } } return hostCPUTimes{}, false } // readHostMemoryBytes returns MemTotal and MemAvailable in bytes. func readHostMemoryBytes() (total, available uint64, ok bool) { raw, err := os.ReadFile("/proc/meminfo") if err != nil { return 0, 0, false } return parseMeminfo(string(raw)) } // readHostMemory reports used/total bytes and the used percentage. func readHostMemory() (percent float64, used, total uint64) { total, available, ok := readHostMemoryBytes() if !ok || total == 0 { return 0, 0, 0 } used = total - available return clampPercent(float64(used) * 100 / float64(total)), used, total } // readHostDisk reports root filesystem usage the way df does: usable space is // total minus reserved blocks, and the percentage is used/(used+available). func readHostDisk() (percent float64, used, total uint64) { var stat unix.Statfs_t if err := unix.Statfs("/", &stat); err != nil || stat.Blocks == 0 { return 0, 0, 0 } blockSize := uint64(stat.Bsize) total = stat.Blocks * blockSize free := stat.Bfree * blockSize available := stat.Bavail * blockSize used = total - free if denominator := used + available; denominator > 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 }