diff --git a/internal/hardware/hardware.go b/internal/hardware/hardware.go index 5b5fe10..1ed5916 100644 --- a/internal/hardware/hardware.go +++ b/internal/hardware/hardware.go @@ -26,15 +26,44 @@ import ( ) 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 + Model string `json:"model,omitempty"` // "GMKtec NucBox G3" + Serial string `json:"serial,omitempty"` + Family string `json:"family,omitempty"` + Kernel string `json:"kernel,omitempty"` + Arch string `json:"arch,omitempty"` + Virt string `json:"virt,omitempty"` // "kvm", "lxc", … when this is not real hardware +} + +// Slot is an expansion slot as the firmware describes it. Worth treating with suspicion on +// a mini PC, where the table is often copied from a reference design and lists slots the +// case has no room for. +type Slot struct { + Designation string `json:"designation"` + Type string `json:"type,omitempty"` + Usage string `json:"usage,omitempty"` // Available | In Use | Unknown + Length string `json:"length,omitempty"` + Width string `json:"width,omitempty"` + Bus string `json:"bus,omitempty"` // PCI address of what is in it + Device string `json:"device,omitempty"` // and what that turns out to be +} + +// Board is the motherboard and the firmware on it. +type Board struct { + Maker string `json:"maker,omitempty"` + Product string `json:"product,omitempty"` + Version string `json:"version,omitempty"` + Serial string `json:"serial,omitempty"` + AssetTag string `json:"asset_tag,omitempty"` + Type string `json:"type,omitempty"` // "Motherboard", "Processor Module", … + Location string `json:"location,omitempty"` // where it sits in the chassis + Chassis string `json:"chassis,omitempty"` + ChassisMaker string `json:"chassis_maker,omitempty"` + BIOS string `json:"bios,omitempty"` // vendor and version + BIOSDate string `json:"bios_date,omitempty"` + BIOSRevision string `json:"bios_revision,omitempty"` + ROMSize string `json:"rom_size,omitempty"` + UEFI bool `json:"uefi,omitempty"` + Slots []Slot `json:"slots,omitempty"` } type Cache struct { @@ -114,6 +143,7 @@ type GPU struct { type Report struct { At time.Time `json:"at"` System System `json:"system"` + Board Board `json:"board"` CPU CPU `json:"cpu"` Memory Memory `json:"memory"` Drives []Drive `json:"drives"` @@ -159,6 +189,7 @@ func collect(ctx context.Context) *Report { r.Notes = append(r.Notes, note) } r.System = system(ctx, dmi) + r.Board = board(dmi, pciNames(ctx)) r.CPU = cpu(ctx, dmi) r.Memory = memory(dmi) if len(r.Memory.Modules) == 0 && note == "" { @@ -180,6 +211,10 @@ type record struct { V map[string]string } +// listKey holds every doubly-indented line of a record — dmidecode's lists, which have no +// key of their own: BIOS characteristics, board features, slot characteristics. +const listKey = "\x00list" + 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 @@ -193,7 +228,7 @@ func readDMI(ctx context.Context) ([]record, string) { } tool = "/usr/sbin/dmidecode" } - args := []string{tool, "-t", "bios", "-t", "system", "-t", "baseboard", "-t", "chassis", "-t", "processor", "-t", "memory"} + args := []string{tool, "-t", "bios", "-t", "system", "-t", "baseboard", "-t", "chassis", "-t", "slot", "-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" @@ -223,12 +258,16 @@ func parse(text string) []record { cur = &recs[len(recs)-1] continue } - if cur == nil || !strings.HasPrefix(line, "\t") || strings.HasPrefix(line, "\t\t") { + if cur == nil || !strings.HasPrefix(line, "\t") { if strings.TrimSpace(line) == "" { cur = nil } continue } + if strings.HasPrefix(line, "\t\t") { // a list under the field above it, e.g. BIOS characteristics + cur.V[listKey] += strings.TrimSpace(line) + "\n" + continue + } k, v, ok := strings.Cut(strings.TrimSpace(line), ":") if !ok { continue @@ -280,19 +319,12 @@ func all(recs []record, t int) []record { // ---- 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) + sys := first(dmi, 1) 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 = "" + Model: join(sys.get("Manufacturer"), sys.get("Product Name")), + Serial: sys.get("Serial Number"), + Family: sys.get("Family"), + Arch: runtimeArch(), } s.Kernel = readFile("/proc/sys/kernel/osrelease") if s.Model == "" { // no SMBIOS: a Pi, a Mac, anything with a device tree @@ -306,6 +338,53 @@ func system(ctx context.Context, dmi []record) System { return s } +// board reads the motherboard, the chassis it sits in, the firmware on it and the +// expansion slots the firmware claims. All of it is SMBIOS, so a machine without one — a +// Mac, a Pi — gets an empty board, and the page says so rather than inventing one. +func board(dmi []record, pci map[string]string) Board { + b, ch, bios := first(dmi, 2), first(dmi, 3), first(dmi, 0) + out := Board{ + Maker: b.get("Manufacturer"), + Product: b.get("Product Name"), + Version: b.get("Version"), + Serial: b.get("Serial Number"), + AssetTag: b.get("Asset Tag"), + Type: b.get("Type"), + Location: b.get("Location In Chassis"), + Chassis: ch.get("Type"), + ChassisMaker: ch.get("Manufacturer"), + BIOS: join(bios.get("Vendor"), bios.get("Version")), + BIOSDate: bios.get("Release Date"), + BIOSRevision: bios.get("BIOS Revision"), + } + // "ROM Size: 0 MB" is a board that never filled the field in, not a board with no ROM + if rom := bios.get("ROM Size"); rom != "" && !strings.HasPrefix(rom, "0 ") { + out.ROMSize = rom + } + if out.Product == out.Maker { + out.Product = "" // a board named after its maker says nothing twice + } + out.UEFI = strings.Contains(bios.V[listKey], "UEFI") // a characteristics line, not a field + for _, sl := range all(dmi, 9) { + s := Slot{ + Designation: sl.get("Designation"), + Type: sl.get("Type"), + Usage: sl.get("Current Usage"), + Length: sl.get("Length"), + Width: sl.get("Data Bus Width"), + } + // every slot on some boards claims 0000:00:00.0, which is the host bridge and + // certainly not what is plugged into that slot + if bus := sl.get("Bus Address"); bus != "" && bus != "0000:00:00.0" { + s.Bus, s.Device = bus, pci[bus] + } + if s.Designation != "" { + out.Slots = append(out.Slots, s) + } + } + return out +} + func join(parts ...string) string { var keep []string for _, p := range parts { @@ -667,6 +746,28 @@ func gpus(ctx context.Context) []GPU { return out } +// pciNames maps a PCI address to a readable name, so a slot the firmware says is in use +// can say what is in it. +func pciNames(ctx context.Context) map[string]string { + m := map[string]string{} + if _, err := exec.LookPath("lspci"); err != nil { + return m + } + out, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output() + if err != nil { + return m + } + for _, line := range strings.Split(string(out), "\n") { + slot, _, _ := strings.Cut(line, " ") + f := quoted.FindAllStringSubmatch(line, -1) + if slot == "" || len(f) < 3 { + continue + } + m[slot] = strings.TrimSpace(stripID(f[1][1]) + " " + stripID(f[2][1])) + } + return m +} + func displayClass(c string) string { switch { case strings.Contains(c, "[0300]"): diff --git a/web/src/Hardware.tsx b/web/src/Hardware.tsx index 2ead6a0..8ca9b76 100644 --- a/web/src/Hardware.tsx +++ b/web/src/Hardware.tsx @@ -2,7 +2,7 @@ // 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 { api, Hardware as HW, HWBoard, HWDrive, HWGPU, HWModule } from './api' import { fmtBytes } from './fmt' import { Btn, Loading } from './ui' @@ -87,6 +87,58 @@ function Gpu({ g }: { g: HWGPU }) { ) } +// The board and the firmware on it. Everything here is SMBIOS, so a machine without one +// — a Mac, a Pi — gets a card that says as much instead of a column of blanks. +function Motherboard({ b }: { b: HWBoard }) { + const named = b.maker || b.product || b.bios + // A board that reports every slot "Available" is not telling us anything — mini PCs + // copy the slot table out of a reference design and list slots the case has no room + // for. Only worth showing when the firmware actually tracks what is plugged in. + const slots = (b.slots ?? []).some((s) => s.usage === 'In Use') ? (b.slots ?? []) : [] + return ( +
+

Motherboard

+ {!named &&
This machine has no SMBIOS table, so its firmware doesn't say what board it is.
} + {named && ( +
+

+

+

+

+

+

+

+

+

+ )} + {(b.bios || b.bios_date) && ( +
+

+

+

+

+

+

+ )} + {slots.length > 0 && ( +
+
Expansion slotsas the firmware lists them
+ + + {slots.map((s, i) => ( + + + + + + ))} +
SlotTypeWhat is in it
{s.designation}{[s.type, s.length && s.length !== 'Other' ? s.length.toLowerCase() : ''].filter(Boolean).join(' · ')}{s.usage === 'In Use' ? (s.device ?? s.bus ?? 'in use') : {(s.usage ?? '').toLowerCase() || '—'}}
+
+ )} +
+ ) +} + export default function Hardware() { const [hw, setHw] = useState(null) const [err, setErr] = useState('') @@ -100,7 +152,7 @@ export default function Hardware() { 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 { system: sys, board, cpu, memory: mem } = hw const drives = hw.drives ?? [] const gpus = hw.gpus ?? [] const capacity = drives.reduce((n, d) => n + d.size, 0) @@ -191,14 +243,13 @@ export default function Hardware() { {gpus.length === 0 &&
No display adapter found.
} + +

System

-

-

-

-

+

diff --git a/web/src/api.ts b/web/src/api.ts index 8a2854e..dbdcbcd 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -118,7 +118,13 @@ export interface DockerHealth { configured: boolean; state: string; status?: str 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 HWSystem { model?: string; serial?: string; family?: string; kernel?: string; arch?: string; virt?: string } +export interface HWSlot { designation: string; type?: string; usage?: string; length?: string; width?: string; bus?: string; device?: string } +export interface HWBoard { + maker?: string; product?: string; version?: string; serial?: string; asset_tag?: string; type?: string; location?: string + chassis?: string; chassis_maker?: string; bios?: string; bios_date?: string; bios_revision?: string; rom_size?: string + uefi?: boolean; slots?: HWSlot[] +} export interface HWCache { level: string; size: string } export interface HWCPU { model?: string; vendor?: string; arch?: string; sockets?: number; socket_names?: string[] @@ -133,7 +139,7 @@ export interface HWModule { 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 Hardware { at: string; system: HWSystem; board: HWBoard; 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[] }