From 776bcb236db4a344d81f41d7bcec0f1a6d0e02db Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 25 Sep 2026 21:41:40 -0700 Subject: [PATCH] A Hardware tab: what the machine is made of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per machine, like every other page. Four tiles — processor, memory, storage, graphics — over a card for each. Memory is the part that had nowhere to live before: every slot, with the module's size, type, form factor, rated and running speed, maker, part number, serial, rank and voltage, and the empty slots marked empty. A module whose maker is only a JEDEC id gets a name when it is one we know and keeps the number when it is not, because a wrong name is worse than a code. Everything is read from what Debian already has: dmidecode for the parts only the firmware knows, lscpu, lspci, sysfs. None of it changes under a running kernel, so it is read once and kept for ten minutes; Re-read is for the one thing that does change, a drive plugged in. Every reader is allowed to come back empty. A machine with no dmidecode, no root to read it with, or no PCI bus at all still gets a page, with a note saying what was missing and why. Checked against the Mac mini, which is all three cases at once: no SMBIOS memory table, a GPU only the device tree knows about, and cores that are not all alike — lscpu counts one cluster as "cores per socket" there, so without SMT the thread count is the honest answer. Co-Authored-By: Claude Opus 5 --- PLAN.md | 17 + internal/api/hardware.go | 13 + internal/api/server.go | 5 +- internal/hardware/hardware.go | 742 +++++++++++++++++++++++++++++ internal/hardware/hardware_test.go | 103 ++++ web/src/Hardware.tsx | 211 ++++++++ web/src/Shell.tsx | 9 +- web/src/api.ts | 19 + 8 files changed, 1115 insertions(+), 4 deletions(-) create mode 100644 internal/api/hardware.go create mode 100644 internal/hardware/hardware.go create mode 100644 internal/hardware/hardware_test.go create mode 100644 web/src/Hardware.tsx diff --git a/PLAN.md b/PLAN.md index 37209c4..82cef34 100644 --- a/PLAN.md +++ b/PLAN.md @@ -177,6 +177,23 @@ something long in one, switch away and back, refresh — it's all still there. the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms and 10 min at 2 s. Still to do: persistent history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges. +- **Hardware tab** — **shipped 2026-09-25** (`internal/hardware`, `internal/api/hardware.go`, + `Hardware.tsx`): what the machine is physically made of, per machine like every other page. + Four headline tiles (processor, memory, storage, graphics) over cards for each: the + processor with its cores, clock range, socket, caches, microcode and the handful of feature + flags anyone asks about; **every memory slot** with size, type, form factor, rated and + running speed, maker, part number, serial, rank and voltage, and the empty ones marked + empty; each drive with model, size, bus, firmware and serial; each display adapter with its + driver, PCI address and video memory; and the board itself — model, firmware and date, + serial, kernel. Read from what Debian already has (dmidecode for what only the firmware + knows, lscpu, lspci, sysfs) and cached for ten minutes, since none of it changes under a + running kernel; "Re-read" is there for the one thing that does, a drive plugged in. + Every reader may come back empty and the page says what it could not read and why: a + machine with no dmidecode, no root, or no PCI bus still gets a page. Verified on `core` + (SMBIOS, Intel graphics) and on the Mac mini (Asahi: no SMBIOS memory table, a + device-tree GPU, heterogeneous cores — where lscpu's "cores per socket" counts one + cluster, so without SMT the thread count is the honest core count). + - **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`, `internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`; reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID; diff --git a/internal/api/hardware.go b/internal/api/hardware.go new file mode 100644 index 0000000..ba9b60d --- /dev/null +++ b/internal/api/hardware.go @@ -0,0 +1,13 @@ +package api + +// The Hardware tab: what the machine is made of. Read once and remembered — none of it +// changes while the kernel runs — with ?refresh=1 for the one thing that can, a drive +// plugged in or pulled out. + +import ( + "net/http" +) + +func (s *Server) hardwareReport(w http.ResponseWriter, r *http.Request) { + jsonOK(w, 200, s.hardware.Report(r.Context(), r.URL.Query().Get("refresh") == "1")) +} diff --git a/internal/api/server.go b/internal/api/server.go index 11e0c3c..01361de 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -33,6 +33,7 @@ import ( "hamlab/internal/config" "hamlab/internal/cron" "hamlab/internal/files" + "hamlab/internal/hardware" "hamlab/internal/metrics" "hamlab/internal/nodes" "hamlab/internal/priv" @@ -67,6 +68,7 @@ type Server struct { tasks *tasks.Manager updates *updates.Manager storage *storage.Manager + hardware *hardware.Manager space *space.Manager update updateState events eventLog @@ -87,7 +89,7 @@ type Server struct { func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server { s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version, - started: time.Now(), user: os.Getenv("USER")} + hardware: hardware.New(), started: time.Now(), user: os.Getenv("USER")} self, err := os.Executable() if err != nil { self = "hamlab" @@ -261,6 +263,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename)) m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete)) m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod)) + m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport)) m.HandleFunc("GET /api/storage", s.auth(s.storageOverview)) m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart)) m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest)) diff --git a/internal/hardware/hardware.go b/internal/hardware/hardware.go new file mode 100644 index 0000000..5b5fe10 --- /dev/null +++ b/internal/hardware/hardware.go @@ -0,0 +1,742 @@ +// Package hardware is what is physically in the machine: the board it is, its processor, +// the memory modules in its slots, the drives attached to it and the graphics in it. +// +// None of this changes while the machine is running, so it is read once and remembered. +// Everything here is read from what Debian and its kin already have — dmidecode for the +// parts only the firmware knows, lscpu, lspci, sysfs — and every reader is allowed to +// come back empty: a machine with no dmidecode, no PCI bus or no root still gets a page, +// with a note saying what could not be read and why. +package hardware + +import ( + "bufio" + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "hamlab/internal/priv" + "hamlab/internal/storage" +) + +type System struct { + Model string `json:"model,omitempty"` // "GMKtec NucBox G3" + Serial string `json:"serial,omitempty"` + Board string `json:"board,omitempty"` // motherboard, when it differs from the model + Chassis string `json:"chassis,omitempty"` + BIOS string `json:"bios,omitempty"` + BIOSDate string `json:"bios_date,omitempty"` + Kernel string `json:"kernel,omitempty"` + Arch string `json:"arch,omitempty"` + Virt string `json:"virt,omitempty"` // "kvm", "lxc", … when this is not real hardware +} + +type Cache struct { + Level string `json:"level"` // L1d, L1i, L2, L3 + Size string `json:"size"` // as lscpu puts it: "6 MiB (1 instance)" +} + +type CPU struct { + Model string `json:"model,omitempty"` + Vendor string `json:"vendor,omitempty"` + Arch string `json:"arch,omitempty"` + Sockets int `json:"sockets,omitempty"` + SocketNames []string `json:"socket_names,omitempty"` // what the firmware calls each socket + Cores int `json:"cores,omitempty"` + Threads int `json:"threads,omitempty"` + ThreadsPerCore int `json:"threads_per_core,omitempty"` + MinMHz float64 `json:"min_mhz,omitempty"` + MaxMHz float64 `json:"max_mhz,omitempty"` + Family string `json:"family,omitempty"` + ModelID string `json:"model_id,omitempty"` + Stepping string `json:"stepping,omitempty"` + Microcode string `json:"microcode,omitempty"` + Virtualization string `json:"virtualization,omitempty"` + Caches []Cache `json:"caches,omitempty"` + Flags []string `json:"flags,omitempty"` // the handful anyone looks for +} + +type Module struct { + Locator string `json:"locator"` // the slot, as the board labels it + Bank string `json:"bank,omitempty"` + Empty bool `json:"empty,omitempty"` + Size uint64 `json:"size,omitempty"` + Type string `json:"type,omitempty"` // DDR4 + FormFactor string `json:"form_factor,omitempty"` // SODIMM + SpeedMTs int `json:"speed_mts,omitempty"` // what the module can do + RunningMTs int `json:"running_mts,omitempty"` // what the board is running it at + Maker string `json:"maker,omitempty"` + Part string `json:"part,omitempty"` + Serial string `json:"serial,omitempty"` + Rank int `json:"rank,omitempty"` + Voltage string `json:"voltage,omitempty"` +} + +type Memory struct { + Total uint64 `json:"total"` // what the kernel can use + Installed uint64 `json:"installed,omitempty"` // what the modules add up to + MaxCapacity uint64 `json:"max_capacity,omitempty"` // what the board will take + Slots int `json:"slots,omitempty"` + Filled int `json:"filled,omitempty"` + ECC string `json:"ecc,omitempty"` + Modules []Module `json:"modules,omitempty"` +} + +type Drive struct { + Name string `json:"name"` + Kind string `json:"kind"` // nvme | ssd | hdd | usb | emmc | sd | other + Model string `json:"model,omitempty"` + Vendor string `json:"vendor,omitempty"` + Serial string `json:"serial,omitempty"` + Firmware string `json:"firmware,omitempty"` + Tran string `json:"tran,omitempty"` + Size uint64 `json:"size"` + Rotational bool `json:"rotational,omitempty"` + Removable bool `json:"removable,omitempty"` +} + +type GPU struct { + Model string `json:"model,omitempty"` + Vendor string `json:"vendor,omitempty"` + Slot string `json:"slot,omitempty"` // PCI address, when it is on a bus + Driver string `json:"driver,omitempty"` + Node string `json:"node,omitempty"` // card0 + Memory uint64 `json:"memory,omitempty"` + Class string `json:"class,omitempty"` // VGA | 3D | Display +} + +type Report struct { + At time.Time `json:"at"` + System System `json:"system"` + CPU CPU `json:"cpu"` + Memory Memory `json:"memory"` + Drives []Drive `json:"drives"` + GPUs []GPU `json:"gpus"` + Notes []string `json:"notes,omitempty"` // what could not be read, and why +} + +// Manager remembers the last report. Hardware does not change under a running kernel — +// the one thing that would, a drive plugged in, is what Refresh is for. +type Manager struct { + mu sync.Mutex + last *Report + at time.Time +} + +func New() *Manager { return &Manager{} } + +const keepFor = 10 * time.Minute + +func (m *Manager) Report(ctx context.Context, refresh bool) *Report { + m.mu.Lock() + if !refresh && m.last != nil && time.Since(m.at) < keepFor { + r := m.last + m.mu.Unlock() + return r + } + m.mu.Unlock() + + r := collect(ctx) + + m.mu.Lock() + m.last, m.at = r, time.Now() + m.mu.Unlock() + return r +} + +func collect(ctx context.Context) *Report { + ctx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + r := &Report{At: time.Now()} + dmi, note := readDMI(ctx) + if note != "" { + r.Notes = append(r.Notes, note) + } + r.System = system(ctx, dmi) + r.CPU = cpu(ctx, dmi) + r.Memory = memory(dmi) + if len(r.Memory.Modules) == 0 && note == "" { + r.Notes = append(r.Notes, "memory modules: the firmware's table says nothing about this machine's slots") + } + r.Drives = drives(ctx) + r.GPUs = gpus(ctx) + if len(r.GPUs) == 0 { + r.Notes = append(r.Notes, "graphics: nothing on the PCI bus and no kernel display device") + } + return r +} + +// ---- dmidecode ---- + +// record is one entry of the firmware's table: its DMI type and its key/value lines. +type record struct { + Type int + V map[string]string +} + +var handleRe = regexp.MustCompile(`^Handle 0x[0-9A-Fa-f]+, DMI type (\d+)`) + +// readDMI runs dmidecode once for every type the page wants and parses the lot. It needs +// root; without it the page loses the board, the memory modules and the socket names, and +// says so rather than pretending the machine has none. +func readDMI(ctx context.Context) ([]record, string) { + tool, err := exec.LookPath("dmidecode") + if err != nil { + if _, e := os.Stat("/usr/sbin/dmidecode"); e != nil { + return nil, "the board and the memory modules need dmidecode, which isn't installed here" + } + tool = "/usr/sbin/dmidecode" + } + args := []string{tool, "-t", "bios", "-t", "system", "-t", "baseboard", "-t", "chassis", "-t", "processor", "-t", "memory"} + if os.Geteuid() != 0 { + if !priv.Available() { + return nil, "the board and the memory modules come from the firmware's table, which only root may read" + } + args = priv.Wrap(args...) + } + out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output() + if err != nil { + return nil, "the firmware's table could not be read: " + strings.TrimSpace(err.Error()) + } + return parse(string(out)), "" +} + +// parse turns dmidecode's output into records: a handle line starts one, and the single +// indented "Key: Value" lines under it are its fields. Doubly-indented lines are the +// continuation of a list (a processor's flags), which nothing here reads. +func parse(text string) []record { + var recs []record + var cur *record + sc := bufio.NewScanner(strings.NewReader(text)) + sc.Buffer(make([]byte, 0, 64<<10), 2<<20) + for sc.Scan() { + line := sc.Text() + if m := handleRe.FindStringSubmatch(line); m != nil { + t, _ := strconv.Atoi(m[1]) + recs = append(recs, record{Type: t, V: map[string]string{}}) + cur = &recs[len(recs)-1] + continue + } + if cur == nil || !strings.HasPrefix(line, "\t") || strings.HasPrefix(line, "\t\t") { + if strings.TrimSpace(line) == "" { + cur = nil + } + continue + } + k, v, ok := strings.Cut(strings.TrimSpace(line), ":") + if !ok { + continue + } + cur.V[k] = strings.TrimSpace(v) + } + return recs +} + +func (r record) get(keys ...string) string { + for _, k := range keys { + if v := r.V[k]; v != "" && !unset(v) { + return v + } + } + return "" +} + +// unset is every way the firmware has of saying "nobody filled this in". +func unset(v string) bool { + switch strings.ToLower(v) { + case "", "unknown", "not specified", "not provided", "none", "default string", "to be filled by o.e.m.", + "system manufacturer", "system product name", "to be filled by oem", "n/a", "empty", "no module installed", + "", "other", "not applicable": + return true + } + return false +} + +func first(recs []record, t int) record { + for _, r := range recs { + if r.Type == t { + return r + } + } + return record{Type: t, V: map[string]string{}} +} + +func all(recs []record, t int) []record { + var out []record + for _, r := range recs { + if r.Type == t { + out = append(out, r) + } + } + return out +} + +// ---- system ---- + +func system(ctx context.Context, dmi []record) System { + sys, board, bios, chassis := first(dmi, 1), first(dmi, 2), first(dmi, 0), first(dmi, 3) + s := System{ + Model: join(sys.get("Manufacturer"), sys.get("Product Name")), + Serial: sys.get("Serial Number"), + Board: join(board.get("Manufacturer"), board.get("Product Name")), + Chassis: chassis.get("Type"), + BIOS: join(bios.get("Vendor"), bios.get("Version")), + BIOSDate: bios.get("Release Date"), + Arch: runtimeArch(), + } + // a mini PC names its board after itself — "GMKtec" under "GMKtec NucBox G3" says nothing + if s.Board != "" && (s.Board == s.Model || strings.Contains(strings.ToLower(s.Model), strings.ToLower(s.Board))) { + s.Board = "" + } + s.Kernel = readFile("/proc/sys/kernel/osrelease") + if s.Model == "" { // no SMBIOS: a Pi, a Mac, anything with a device tree + s.Model = strings.TrimRight(readFile("/sys/firmware/devicetree/base/model"), "\x00") + } + if out, err := exec.CommandContext(ctx, "systemd-detect-virt").Output(); err == nil { + if v := strings.TrimSpace(string(out)); v != "" && v != "none" { + s.Virt = v + } + } + return s +} + +func join(parts ...string) string { + var keep []string + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" && !unset(p) { + keep = append(keep, p) + } + } + // "GMKtec GMKtec" reads badly: drop a maker the product already names + if len(keep) == 2 && strings.HasPrefix(strings.ToLower(keep[1]), strings.ToLower(keep[0])) { + return keep[1] + } + return strings.Join(keep, " ") +} + +func readFile(p string) string { + b, err := os.ReadFile(p) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func runtimeArch() string { + if out, err := exec.Command("uname", "-m").Output(); err == nil { + return strings.TrimSpace(string(out)) + } + return "" +} + +// ---- processor ---- + +// worthShowing is the short list of processor features anyone actually asks about; the +// full flag list runs to hundreds and belongs in a terminal. +var worthShowing = []string{"aes", "avx", "avx2", "avx512f", "sha_ni", "vmx", "svm", "sse4_2", "rdrand", "hypervisor"} + +func cpu(ctx context.Context, dmi []record) CPU { + c := CPU{} + f := lscpu(ctx) + c.Model = f["Model name"] + c.Vendor = f["Vendor ID"] + c.Arch = f["Architecture"] + c.Sockets = atoi(f["Socket(s)"]) + c.ThreadsPerCore = atoi(f["Thread(s) per core"]) + c.Threads = atoi(f["CPU(s)"]) + c.Cores = atoi(f["Core(s) per socket"]) * maxInt(1, c.Sockets) + c.MinMHz = atof(f["CPU min MHz"]) + c.MaxMHz = atof(f["CPU max MHz"]) + c.Family, c.ModelID, c.Stepping = f["CPU family"], f["Model"], f["Stepping"] + c.Virtualization = f["Virtualization"] + for _, l := range []string{"L1d", "L1i", "L2", "L3"} { + if v := f[l+" cache"]; v != "" { + c.Caches = append(c.Caches, Cache{Level: l, Size: v}) + } + } + have := map[string]bool{} + for _, fl := range strings.Fields(f["Flags"]) { + have[fl] = true + } + for _, fl := range worthShowing { + if have[fl] { + c.Flags = append(c.Flags, fl) + } + } + for _, p := range all(dmi, 4) { + if s := p.get("Socket Designation"); s != "" { + c.SocketNames = append(c.SocketNames, s) + } + if c.Model == "" { + c.Model = p.get("Version", "Family") + } + if c.MaxMHz == 0 { + if mhz := atof(strings.TrimSuffix(p.get("Max Speed"), " MHz")); mhz > 0 { + c.MaxMHz = mhz + } + } + } + if c.Sockets == 0 { + c.Sockets = len(c.SocketNames) + } + // A processor whose cores are not all alike — Apple's, Intel's P and E cores — is + // reported by lscpu as several groups, and "core(s) per socket" then counts one group + // rather than the chip. Without SMT every thread is a core, so the thread count is the + // honest answer. + if c.ThreadsPerCore == 1 && c.Threads > c.Cores { + c.Cores = c.Threads + } + // /proc/cpuinfo is the only place the running microcode revision is written down + if b, err := os.ReadFile("/proc/cpuinfo"); err == nil { + for _, line := range strings.Split(string(b), "\n") { + if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == "microcode" { + c.Microcode = strings.TrimSpace(v) + break + } + } + } + if c.Model == "" { + c.Model = "unknown processor" + } + return c +} + +func lscpu(ctx context.Context) map[string]string { + f := map[string]string{} + out, err := exec.CommandContext(ctx, "lscpu").Output() + if err != nil { + // no util-linux: /proc/cpuinfo still names the thing and counts it + if b, err := os.ReadFile("/proc/cpuinfo"); err == nil { + n := 0 + for _, line := range strings.Split(string(b), "\n") { + k, v, ok := strings.Cut(line, ":") + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + switch { + case ok && strings.EqualFold(k, "model name"): + f["Model name"] = v + case ok && k == "processor": + n++ + } + } + f["CPU(s)"] = strconv.Itoa(n) + } + return f + } + for _, line := range strings.Split(string(out), "\n") { + k, v, ok := strings.Cut(line, ":") + if !ok || strings.HasPrefix(line, " ") { + continue + } + f[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return f +} + +func atoi(s string) int { n, _ := strconv.Atoi(strings.TrimSpace(s)); return n } + +func atof(s string) float64 { + v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + return v +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// leadingInt reads the number off the front of "3200 MT/s". +func leadingInt(s string) int { + f := strings.Fields(s) + if len(f) == 0 { + return 0 + } + return atoi(f[0]) +} + +// ---- memory ---- + +var sizeRe = regexp.MustCompile(`^(\d+)\s*(kB|KB|MB|GB|TB)$`) + +// dmiSize turns dmidecode's "8 GB" / "16384 MB" into bytes. +func dmiSize(s string) uint64 { + m := sizeRe.FindStringSubmatch(strings.TrimSpace(s)) + if m == nil { + return 0 + } + n, _ := strconv.ParseUint(m[1], 10, 64) + switch strings.ToUpper(m[2]) { + case "KB": + return n << 10 + case "MB": + return n << 20 + case "GB": + return n << 30 + case "TB": + return n << 40 + } + return 0 +} + +func memory(dmi []record) Memory { + m := Memory{} + if b, err := os.ReadFile("/proc/meminfo"); err == nil { + for _, line := range strings.Split(string(b), "\n") { + if k, v, ok := strings.Cut(line, ":"); ok && k == "MemTotal" { + m.Total = uint64(atoi(strings.TrimSuffix(strings.TrimSpace(v), " kB"))) << 10 + break + } + } + } + for _, a := range all(dmi, 16) { + m.MaxCapacity += dmiSize(a.get("Maximum Capacity")) + switch e := a.V["Error Correction Type"]; { + case e == "None": + m.ECC = "non-ECC" // the array says so outright; worth knowing, not worth hiding + case e != "" && !unset(e): + m.ECC = e + } + } + for _, d := range all(dmi, 17) { + mod := Module{ + Locator: d.get("Locator"), + Bank: d.get("Bank Locator"), + Size: dmiSize(d.get("Size")), + } + m.Slots++ + if mod.Locator == "" { + mod.Locator = "slot " + strconv.Itoa(m.Slots) + } + if mod.Size == 0 { + mod.Empty = true + m.Modules = append(m.Modules, mod) + continue + } + m.Filled++ + mod.Type = d.get("Type") + mod.FormFactor = d.get("Form Factor") + mod.SpeedMTs = leadingInt(d.get("Speed")) + mod.RunningMTs = leadingInt(d.get("Configured Memory Speed", "Configured Clock Speed")) + mod.Maker = makerName(d.get("Manufacturer"), d.V["Module Manufacturer ID"]) + mod.Part = d.get("Part Number") + mod.Serial = d.get("Serial Number") + mod.Rank = atoi(d.get("Rank")) + mod.Voltage = d.get("Configured Voltage") + m.Installed += mod.Size + m.Modules = append(m.Modules, mod) + } + return m +} + +// jedec maps the memory makers who turn up in a homelab to their names: SPD carries a +// JEDEC id, and dmidecode prints the raw code when the module's string field is empty. +var jedec = map[string]string{ + "0X2C00": "Micron", "0X802C": "Micron", + "0XCE00": "Samsung", "0X80CE": "Samsung", "0X00CE": "Samsung", + "0XAD00": "SK hynix", "0X80AD": "SK hynix", + "0X9801": "Kingston", "0X7F98": "Kingston", "0X0198": "Kingston", + "0X859B": "Crucial", "0X04CB": "A-DATA", "0X04CD": "G.Skill", + "0X029E": "Corsair", "0X0B45": "Wilk Elektronik", "0X830B": "Wilk Elektronik", + "0X0443": "Ramaxel", "0X0194": "Smart Modular", +} + +// makerName prefers what the module says it is; a bare JEDEC id becomes a name when it is +// one we know, and stays the id when it is not — a wrong name is worse than a number. +func makerName(v, id string) string { + if v != "" && !unset(v) && !strings.HasPrefix(v, "0x") { + return v + } + code := v + if code == "" { + code = id + } + if n, ok := jedec[strings.ToUpper(code)]; ok { + return n + } + if v != "" && !unset(v) { + return v + } + return "" +} + +// ---- drives ---- + +func drives(ctx context.Context) []Drive { + devs, err := storage.Devices(ctx) + if err != nil { + return nil + } + out := make([]Drive, 0, len(devs)) + for _, d := range devs { + out = append(out, Drive{ + Name: d.Name, Kind: d.Kind, Model: d.Model, Vendor: d.Vendor, Serial: d.Serial, + Tran: d.Tran, Size: d.Size, Rotational: d.Rotational, Removable: d.Removable, + Firmware: firmwareOf(d.Name), + }) + } + return out +} + +// firmwareOf reads a drive's firmware revision. An NVMe device is folded to its +// controller ("nvme0"), which has no /sys/block entry of its own — the revision is on the +// controller in /sys/class/nvme. +func firmwareOf(name string) string { + return firstNonEmpty( + readFile("/sys/block/"+name+"/device/firmware_rev"), + readFile("/sys/block/"+name+"/device/rev"), + readFile("/sys/class/nvme/"+name+"/firmware_rev"), + ) +} + +func firstNonEmpty(v ...string) string { + for _, s := range v { + if s != "" { + return s + } + } + return "" +} + +// ---- graphics ---- + +// lspci -mm quotes every field, so a device name can hold the spaces and brackets it +// likes: `"VGA compatible controller [0300]" "Intel Corporation [8086]" "Alder Lake-N …"`. +var quoted = regexp.MustCompile(`"([^"]*)"`) + +// stripID drops the trailing PCI id from `Alder Lake-N [UHD Graphics] [46d1]`. +func stripID(s string) string { + if i := strings.LastIndex(s, " ["); i > 0 && strings.HasSuffix(s, "]") { + return strings.TrimSpace(s[:i]) + } + return s +} + +func gpus(ctx context.Context) []GPU { + var out []GPU + seen := map[string]bool{} // PCI addresses already accounted for + if _, err := exec.LookPath("lspci"); err == nil { + drivers := pciDrivers(ctx) + if b, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output(); err == nil { + for _, line := range strings.Split(string(b), "\n") { + slot, _, _ := strings.Cut(line, " ") + fields := quoted.FindAllStringSubmatch(line, -1) + if len(fields) < 3 { + continue + } + kind := displayClass(fields[0][1]) + if kind == "" { + continue + } + seen[slot] = true + g := GPU{Slot: slot, Class: kind, Vendor: stripID(fields[1][1]), Model: stripID(fields[2][1]), Driver: drivers[slot]} + g.Node, g.Memory = drmFor(slot) + out = append(out, g) + } + } + } + // A GPU that is not on a PCI bus at all — an Apple Silicon Mac, a Pi, anything with a + // device tree — is only visible as a kernel display device. + cards, _ := filepath.Glob("/sys/class/drm/card[0-9]") + for _, c := range cards { + dev, err := filepath.EvalSymlinks(c + "/device") + if err != nil || seen[filepath.Base(dev)] { + continue + } + g := GPU{Node: filepath.Base(c), Class: "Display"} + if drv, err := filepath.EvalSymlinks(dev + "/driver"); err == nil { + g.Driver = filepath.Base(drv) + } + g.Model = deviceTreeName(dev) + if g.Model == "" && g.Driver != "" { + g.Model = g.Driver + " display" + } + if g.Model == "" { + continue + } + g.Memory = vram(dev) + out = append(out, g) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Slot < out[j].Slot }) + return out +} + +func displayClass(c string) string { + switch { + case strings.Contains(c, "[0300]"): + return "VGA" + case strings.Contains(c, "[0302]"): + return "3D" + case strings.Contains(c, "[0301]"), strings.Contains(c, "[0380]"): + return "Display" + } + return "" +} + +// pciDrivers maps a PCI address to the kernel module driving it. +func pciDrivers(ctx context.Context) map[string]string { + m := map[string]string{} + out, err := exec.CommandContext(ctx, "lspci", "-D", "-k").Output() + if err != nil { + return m + } + slot := "" + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") { + slot, _, _ = strings.Cut(line, " ") + continue + } + if v, ok := strings.CutPrefix(strings.TrimSpace(line), "Kernel driver in use:"); ok && slot != "" { + m[slot] = strings.TrimSpace(v) + } + } + return m +} + +// drmFor finds the kernel display device for a PCI address, and its video memory when the +// driver publishes it (amdgpu does; Intel's integrated graphics have none of their own). +func drmFor(slot string) (string, uint64) { + cards, _ := filepath.Glob("/sys/class/drm/card[0-9]") + for _, c := range cards { + dev, err := filepath.EvalSymlinks(c + "/device") + if err != nil || filepath.Base(dev) != slot { + continue + } + return filepath.Base(c), vram(dev) + } + return "", 0 +} + +func vram(dev string) uint64 { + for _, f := range []string{"mem_info_vram_total", "mem_info_vis_vram_total"} { + if v := readFile(dev + "/" + f); v != "" { + n, _ := strconv.ParseUint(v, 10, 64) + return n + } + } + return 0 +} + +// deviceTreeName reads what a device-tree machine calls its graphics: the compatible +// string, tidied — "apple,agx-g13g" becomes "Apple agx-g13g". +func deviceTreeName(dev string) string { + c := readFile(dev + "/of_node/compatible") + if c == "" { + return "" + } + c = strings.TrimRight(strings.Split(c, "\x00")[0], "\x00") + maker, rest, ok := strings.Cut(c, ",") + if !ok { + return c + } + if maker != "" { + maker = strings.ToUpper(maker[:1]) + maker[1:] + } + return strings.TrimSpace(maker + " " + rest) +} diff --git a/internal/hardware/hardware_test.go b/internal/hardware/hardware_test.go new file mode 100644 index 0000000..82b4208 --- /dev/null +++ b/internal/hardware/hardware_test.go @@ -0,0 +1,103 @@ +package hardware + +import "testing" + +func TestDMISize(t *testing.T) { + for in, want := range map[string]uint64{ + "8 GB": 8 << 30, "16384 MB": 16384 << 20, "512 kB": 512 << 10, "1 TB": 1 << 40, + "No Module Installed": 0, "": 0, "8 GiB": 0, + } { + if got := dmiSize(in); got != want { + t.Errorf("dmiSize(%q) = %d, want %d", in, got, want) + } + } +} + +func TestMakerName(t *testing.T) { + cases := []struct{ v, id, want string }{ + {"Samsung", "", "Samsung"}, // the module says who it is + {"0x0B45", "Bank 12, Hex 0x45", "Wilk Elektronik"}, // only a JEDEC id, and one we know + {"0xFFFF", "", "0xFFFF"}, // one we don't: the number, not a guess + {"Unknown", "", ""}, + {"", "0xCE00", "Samsung"}, + } + for _, c := range cases { + if got := makerName(c.v, c.id); got != c.want { + t.Errorf("makerName(%q, %q) = %q, want %q", c.v, c.id, got, c.want) + } + } +} + +func TestStripIDAndDisplayClass(t *testing.T) { + if got := stripID("Alder Lake-N [UHD Graphics] [46d1]"); got != "Alder Lake-N [UHD Graphics]" { + t.Errorf("stripID = %q", got) + } + if got := stripID("no brackets here"); got != "no brackets here" { + t.Errorf("stripID left a plain name alone wrong: %q", got) + } + if displayClass(`VGA compatible controller [0300]`) != "VGA" { + t.Error("a VGA controller should be graphics") + } + if displayClass(`Ethernet controller [0200]`) != "" { + t.Error("a network card is not graphics") + } +} + +func TestLeadingIntAndJoin(t *testing.T) { + if leadingInt("3200 MT/s") != 3200 || leadingInt("") != 0 || leadingInt("Unknown") != 0 { + t.Error("leadingInt") + } + if got := join("GMKtec", "GMKtec NucBox G3"); got != "GMKtec NucBox G3" { + t.Errorf("a maker the product already names should not be said twice: %q", got) + } + if got := join("Dell Inc.", "OptiPlex 7040"); got != "Dell Inc. OptiPlex 7040" { + t.Errorf("join = %q", got) + } + if got := join("To Be Filled By O.E.M.", "System Product Name"); got != "" { + t.Errorf("firmware boilerplate is not a model: %q", got) + } +} + +const sample = `# dmidecode 3.6 +Handle 0x0028, DMI type 17, 92 bytes +Memory Device + Array Handle: 0x0027 + Size: 8 GB + Form Factor: SODIMM + Locator: Controller0-ChannelA-DIMM0 + Type: DDR4 + Speed: 3200 MT/s + Manufacturer: 0x0B45 + Part Number: WPBH32D408SWD-8G + Configured Memory Speed: 2933 MT/s + Rank: 1 + +Handle 0x0029, DMI type 17, 92 bytes +Memory Device + Size: No Module Installed + Locator: Controller1-ChannelA-DIMM0 + Type: Unknown +` + +// parseRecords is what readDMI does once dmidecode has answered; this exercises it +// without needing root or the tool. +func TestMemoryFromRecords(t *testing.T) { + recs := parse(sample) + m := memory(recs) + if m.Slots != 2 || m.Filled != 1 { + t.Fatalf("slots %d filled %d, want 2 and 1", m.Slots, m.Filled) + } + mod := m.Modules[0] + if mod.Size != 8<<30 || mod.Type != "DDR4" || mod.Maker != "Wilk Elektronik" || mod.Part != "WPBH32D408SWD-8G" { + t.Errorf("first module read wrong: %+v", mod) + } + if mod.SpeedMTs != 3200 || mod.RunningMTs != 2933 { + t.Errorf("a module running below its rating should say both: %d / %d", mod.RunningMTs, mod.SpeedMTs) + } + if !m.Modules[1].Empty || m.Modules[1].Locator != "Controller1-ChannelA-DIMM0" { + t.Errorf("empty slot read wrong: %+v", m.Modules[1]) + } + if m.Installed != 8<<30 { + t.Errorf("installed = %d", m.Installed) + } +} diff --git a/web/src/Hardware.tsx b/web/src/Hardware.tsx new file mode 100644 index 0000000..2ead6a0 --- /dev/null +++ b/web/src/Hardware.tsx @@ -0,0 +1,211 @@ +// Hardware: what this machine is made of — the board, the processor, the memory modules in +// their slots, the drives and the graphics. None of it moves, so the page is a read rather +// than a monitor: what is live (temperature, usage, SMART) belongs to Dashboard and Disks. +import { useCallback, useEffect, useState } from 'react' +import { api, Hardware as HW, HWDrive, HWGPU, HWModule } from './api' +import { fmtBytes } from './fmt' +import { Btn, Loading } from './ui' + +const KIND: Record = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', emmc: 'eMMC', sd: 'SD', other: 'disk' } +const ghz = (mhz?: number) => (mhz ? `${(mhz / 1000).toFixed(mhz >= 1000 ? 2 : 1)} GHz` : '') + +// A row of the "key: value" kind, dropped entirely when there is no value — a machine that +// doesn't report its serial should show one fewer line, not an empty one. +function P({ k, v, mono }: { k: string; v?: string | number | null | false; mono?: boolean }) { + if (v === undefined || v === null || v === false || v === '' || v === 0) return null + return
{k}{v}
+} + +function Module({ m }: { m: HWModule }) { + if (m.empty) { + return ( +
+
{m.locator}empty
+
+ ) + } + const speed = m.running_mts && m.speed_mts && m.running_mts !== m.speed_mts + ? `${m.running_mts} MT/s (rated ${m.speed_mts})` // the board is running it slower than it could + : m.running_mts || m.speed_mts ? `${m.running_mts || m.speed_mts} MT/s` : '' + return ( +
+
+ {fmtBytes(m.size ?? 0)} {m.type} + + {m.locator} +
+
+

+

+

+

+

+

+

+

+
+ ) +} + +function Drive({ d }: { d: HWDrive }) { + return ( +
+
+ {d.model || d.name} + + {KIND[d.kind] ?? d.kind} +
+
+

+

+

+

+

+

+

+

+
+ ) +} + +function Gpu({ g }: { g: HWGPU }) { + return ( +
+
+ {g.model} + + {g.class && {g.class}} +
+
+

+

+

+

+

+

+
+ ) +} + +export default function Hardware() { + const [hw, setHw] = useState(null) + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + const load = useCallback(async (refresh = false) => { + if (refresh) setBusy(true) + try { setHw(await api.hardware(refresh)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy(false) } + }, []) + useEffect(() => { load() }, [load]) + + if (err) return
{err === 'not found' ? "This machine's Ham Lab is older than the Hardware tab. Update it from Machines." : err}
+ if (!hw) return
+ + const { system: sys, cpu, memory: mem } = hw + const drives = hw.drives ?? [] + const gpus = hw.gpus ?? [] + const capacity = drives.reduce((n, d) => n + d.size, 0) + const threads = cpu.threads ?? 0 + const cores = cpu.cores ?? 0 + + return ( +
+
+ {sys.model || 'this machine'} + {sys.virt && {sys.virt}} + + load(true)}>Re-read +
+ +
+
+
Processor
+
20 ? { fontSize: 16 } : undefined}>{cpu.model}
+
{cores ? `${cores} core${cores === 1 ? '' : 's'}` : ''}{threads && threads !== cores ? ` · ${threads} threads` : ''}{cpu.max_mhz ? ` · up to ${ghz(cpu.max_mhz)}` : ''}
+
+
+
Memory
+
{fmtBytes(mem.installed || mem.total)}
+
{mem.slots ? `${mem.filled ?? 0} of ${mem.slots} slot${mem.slots === 1 ? '' : 's'} filled` : 'slots unknown'}{mem.ecc ? ` · ${mem.ecc}` : ''}
+
+
+
Storage
+
{fmtBytes(capacity)}
+
{drives.length} drive{drives.length === 1 ? '' : 's'}
+
+
+
Graphics
+
20 ? { fontSize: 16 } : undefined}>{gpus[0]?.model ?? 'none found'}
+
{gpus.length > 1 ? `${gpus.length} adapters` : gpus[0]?.driver ? `${gpus[0].driver} driver` : ''}
+
+
+ + {(hw.notes ?? []).map((n, i) =>
{n}
)} + +
+
+

Processor

+
+

+

+

1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} /> +

+

+

+

+

+

+

+ {(cpu.caches ?? []).length > 0 && ( +
+ {(cpu.caches ?? []).map((c) =>

)} +

+ )} + {(cpu.flags ?? []).length > 0 && ( +
{(cpu.flags ?? []).map((f) => {f})}
+ )} +
+ +
+

Memory

+
+

+

+

+

+

+
+ {(mem.modules ?? []).map((m, i) => )} + {(mem.modules ?? []).length === 0 &&
The firmware doesn't say what is in the slots.
} +
+
+ +
+

Drives

+ {drives.map((d) => )} + {drives.length === 0 &&
No drives found.
} +
+ +
+

Graphics

+ {gpus.map((g, i) => )} + {gpus.length === 0 &&
No display adapter found.
} +
+ +
+

System

+
+

+

+

+

+

+

+

+

+

+

+
+
+
+ ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 592cb04..7fe42c2 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -11,6 +11,7 @@ import Services from './Services' import Jobs from './Jobs' import Updates from './Updates' import Disks from './Disks' +import Hardware from './Hardware' import ZFS from './ZFS' import KVM from './KVM' import Files from './Files' @@ -57,6 +58,7 @@ const GROUPS: Group[] = [ { id: 'services', label: 'Services', icon: '◈' }, { id: 'jobs', label: 'Jobs', icon: ClockIcon }, { id: 'disks', label: 'Disks', icon: '▤' }, + { id: 'hardware', label: 'Hardware', icon: '⬡' }, { id: 'files', label: 'Files', icon: FolderIcon }, { id: 'usage', label: 'Usage', icon: '▧' }, { id: 'zfs', label: 'ZFS', icon: '⛁' }, @@ -70,8 +72,8 @@ const GROUPS: Group[] = [ { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' -const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] +type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' +const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and @@ -296,6 +298,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'jobs' && } {view === 'updates' && } {view === 'disks' && } + {view === 'hardware' && } {view === 'zfs' && } {view === 'files' && } {termView && {machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} ) diff --git a/web/src/api.ts b/web/src/api.ts index 7fa8c10..8a2854e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -117,6 +117,24 @@ export interface DockerHealthProbe { Start: string; End: string; ExitCode: numbe export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] } export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string } +// ---- hardware (the Hardware tab: what the machine is made of) ---- +export interface HWSystem { model?: string; serial?: string; board?: string; chassis?: string; bios?: string; bios_date?: string; kernel?: string; arch?: string; virt?: string } +export interface HWCache { level: string; size: string } +export interface HWCPU { + model?: string; vendor?: string; arch?: string; sockets?: number; socket_names?: string[] + cores?: number; threads?: number; threads_per_core?: number; min_mhz?: number; max_mhz?: number + family?: string; model_id?: string; stepping?: string; microcode?: string; virtualization?: string + caches?: HWCache[]; flags?: string[] +} +export interface HWModule { + locator: string; bank?: string; empty?: boolean; size?: number; type?: string; form_factor?: string + speed_mts?: number; running_mts?: number; maker?: string; part?: string; serial?: string; rank?: number; voltage?: string +} +export interface HWMemory { total: number; installed?: number; max_capacity?: number; slots?: number; filled?: number; ecc?: string; modules?: HWModule[] } +export interface HWDrive { name: string; kind: string; model?: string; vendor?: string; serial?: string; firmware?: string; tran?: string; size: number; rotational?: boolean; removable?: boolean } +export interface HWGPU { model?: string; vendor?: string; slot?: string; driver?: string; node?: string; memory?: number; class?: string } +export interface Hardware { at: string; system: HWSystem; cpu: HWCPU; memory: HWMemory; drives: HWDrive[] | null; gpus: HWGPU[] | null; notes?: string[] } + export interface DockerLogLine { t: string; m: string; err?: boolean } export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record[]; volumes: Record[]; networks: Record[] } export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean } @@ -253,6 +271,7 @@ export const api = { installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }), tokens: () => req('GET', '/api/tokens', undefined, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), + hardware: (refresh = false) => req('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`), docker: () => req('GET', '/api/docker'), dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`), dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),