Hardware: a Motherboard card

The board, the chassis and the firmware on it, which the System card was
reducing to two lines. Maker, model, version, serial and asset tag; chassis
and where the board sits in it; the firmware's vendor, version, revision,
date, ROM size and whether the machine boots UEFI — that last one is a line
in SMBIOS's characteristics list rather than a field, so the parser now keeps
those lists too.

Expansion slots come with it, with the device found at each occupied slot's
PCI address rather than the address alone. They are only shown when the
firmware tracks occupancy at all: core's NucBox lists five PCIe x1 slots it
does not physically have, all "Available", copied out of a reference design.
grid's board reports real addresses and real occupancy, which is the case
worth printing.

A machine with no SMBIOS gets a card that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-25 22:21:27 -07:00
co-authored by Claude Opus 5
parent 38039d1adc
commit 03e0761f3e
3 changed files with 189 additions and 31 deletions
+124 -23
View File
@@ -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]"):
+57 -6
View File
@@ -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 (
<section className="card">
<h2>Motherboard</h2>
{!named && <div className="empty">This machine has no SMBIOS table, so its firmware doesn't say what board it is.</div>}
{named && (
<div className="props">
<P k="Maker" v={b.maker} />
<P k="Model" v={b.product} />
<P k="Version" v={b.version} />
<P k="Kind" v={b.type} />
<P k="Chassis" v={[b.chassis_maker, b.chassis].filter(Boolean).join(' ')} />
<P k="In chassis" v={b.location} />
<P k="Serial" v={b.serial} mono />
<P k="Asset tag" v={b.asset_tag} mono />
</div>
)}
{(b.bios || b.bios_date) && (
<div className="props" style={{ marginTop: 8 }}>
<P k="Firmware" v={b.bios} />
<P k="Revision" v={b.bios_revision} />
<P k="Dated" v={b.bios_date} />
<P k="ROM" v={b.rom_size} />
<P k="Boots" v={b.uefi ? 'UEFI' : ''} />
</div>
)}
{slots.length > 0 && (
<div style={{ marginTop: 8 }}>
<div className="sub-head"><span className="dname">Expansion slots</span><span className="spacer" /><span className="muted small">as the firmware lists them</span></div>
<table className="dtable">
<thead><tr><th>Slot</th><th>Type</th><th>What is in it</th></tr></thead>
<tbody>{slots.map((s, i) => (
<tr key={s.designation + i}>
<td className="mono small">{s.designation}</td>
<td className="mono small">{[s.type, s.length && s.length !== 'Other' ? s.length.toLowerCase() : ''].filter(Boolean).join(' · ')}</td>
<td className="small" title={s.bus}>{s.usage === 'In Use' ? (s.device ?? s.bus ?? 'in use') : <span className="muted">{(s.usage ?? '').toLowerCase() || '—'}</span>}</td>
</tr>
))}</tbody>
</table>
</div>
)}
</section>
)
}
export default function Hardware() {
const [hw, setHw] = useState<HW | null>(null)
const [err, setErr] = useState('')
@@ -100,7 +152,7 @@ export default function Hardware() {
if (err) return <div className="dash"><div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Hardware tab. Update it from Machines." : err}</div></div>
if (!hw) return <div className="dash"><Loading label="Reading the machine…" /></div>
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 && <div className="empty">No display adapter found.</div>}
</section>
<Motherboard b={board} />
<section className="card">
<h2>System</h2>
<div className="props">
<P k="Model" v={sys.model} />
<P k="Board" v={sys.board} />
<P k="Chassis" v={sys.chassis} />
<P k="Firmware" v={sys.bios} />
<P k="Dated" v={sys.bios_date} />
<P k="Family" v={sys.family} />
<P k="Serial" v={sys.serial} mono />
<P k="Kernel" v={sys.kernel} mono />
<P k="Arch" v={sys.arch} />
+8 -2
View File
@@ -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<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }