A Hardware tab: what the machine is made of

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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-25 21:41:40 -07:00
co-authored by Claude Opus 5
parent 09dc846730
commit 776bcb236d
8 changed files with 1115 additions and 4 deletions
+211
View File
@@ -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<string, string> = { 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 <div><span className="k">{k}</span><span className={mono ? 'mono' : undefined} title={String(v)}>{v}</span></div>
}
function Module({ m }: { m: HWModule }) {
if (m.empty) {
return (
<div className="sub-card">
<div className="sub-head"><span className="dname muted">{m.locator}</span><span className="spacer" /><span className="badge">empty</span></div>
</div>
)
}
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 (
<div className="sub-card">
<div className="sub-head">
<span className="dname">{fmtBytes(m.size ?? 0)} {m.type}</span>
<span className="spacer" />
<span className="model mono">{m.locator}</span>
</div>
<div className="props">
<P k="Maker" v={m.maker} />
<P k="Form" v={m.form_factor} />
<P k="Part" v={m.part} mono />
<P k="Speed" v={speed} />
<P k="Rank" v={m.rank ? `${m.rank}R` : ''} />
<P k="Voltage" v={m.voltage} />
<P k="Serial" v={m.serial} mono />
</div>
</div>
)
}
function Drive({ d }: { d: HWDrive }) {
return (
<div className="sub-card">
<div className="sub-head">
<span className="dname">{d.model || d.name}</span>
<span className="spacer" />
<span className="badge">{KIND[d.kind] ?? d.kind}</span>
</div>
<div className="props">
<P k="Size" v={fmtBytes(d.size)} />
<P k="Device" v={`/dev/${d.name}`} mono />
<P k="Bus" v={d.tran ? d.tran.toUpperCase() : ''} />
<P k="Spins" v={d.rotational ? 'yes — mechanical' : 'no — solid state'} />
<P k="Firmware" v={d.firmware} mono />
<P k="Serial" v={d.serial} mono />
<P k="Vendor" v={d.vendor} />
</div>
</div>
)
}
function Gpu({ g }: { g: HWGPU }) {
return (
<div className="sub-card">
<div className="sub-head">
<span className="dname">{g.model}</span>
<span className="spacer" />
{g.class && <span className="badge">{g.class}</span>}
</div>
<div className="props">
<P k="Vendor" v={g.vendor} />
<P k="Driver" v={g.driver} mono />
<P k="Memory" v={g.memory ? fmtBytes(g.memory) : ''} />
<P k="Slot" v={g.slot} mono />
<P k="Node" v={g.node} mono />
</div>
</div>
)
}
export default function Hardware() {
const [hw, setHw] = useState<HW | null>(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 <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 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 (
<div className="dash">
<div className="dash-bar">
<span className="status"><b>{sys.model || 'this machine'}</b></span>
{sys.virt && <span className="badge" title="this is a virtual machine, so the parts below are what the host handed it">{sys.virt}</span>}
<span className="spacer" style={{ flex: 1 }} />
<Btn className="btn" busy={busy} onClick={() => load(true)}>Re-read</Btn>
</div>
<div className="kpis">
<div className="tile">
<div className="label">Processor</div>
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{cpu.model}</div>
<div className="sub">{cores ? `${cores} core${cores === 1 ? '' : 's'}` : ''}{threads && threads !== cores ? ` · ${threads} threads` : ''}{cpu.max_mhz ? ` · up to ${ghz(cpu.max_mhz)}` : ''}</div>
</div>
<div className="tile">
<div className="label">Memory</div>
<div className="value">{fmtBytes(mem.installed || mem.total)}</div>
<div className="sub">{mem.slots ? `${mem.filled ?? 0} of ${mem.slots} slot${mem.slots === 1 ? '' : 's'} filled` : 'slots unknown'}{mem.ecc ? ` · ${mem.ecc}` : ''}</div>
</div>
<div className="tile">
<div className="label">Storage</div>
<div className="value">{fmtBytes(capacity)}</div>
<div className="sub">{drives.length} drive{drives.length === 1 ? '' : 's'}</div>
</div>
<div className="tile">
<div className="label">Graphics</div>
<div className="value" title={gpus[0]?.model} style={(gpus[0]?.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{gpus[0]?.model ?? 'none found'}</div>
<div className="sub">{gpus.length > 1 ? `${gpus.length} adapters` : gpus[0]?.driver ? `${gpus[0].driver} driver` : ''}</div>
</div>
</div>
{(hw.notes ?? []).map((n, i) => <div className="notice" key={i}>{n}</div>)}
<div className="cards">
<section className="card">
<h2>Processor</h2>
<div className="props">
<P k="Model" v={cpu.model} />
<P k="Vendor" v={cpu.vendor} />
<P k="Cores" v={cores ? `${cores}${cpu.threads_per_core && cpu.threads_per_core > 1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} />
<P k="Clock" v={cpu.min_mhz && cpu.max_mhz ? `${ghz(cpu.min_mhz)} – ${ghz(cpu.max_mhz)}` : ghz(cpu.max_mhz)} />
<P k="Socket" v={(cpu.socket_names ?? []).join(', ')} />
<P k="Arch" v={cpu.arch} />
<P k="Family" v={[cpu.family && `family ${cpu.family}`, cpu.model_id && `model ${cpu.model_id}`, cpu.stepping && `stepping ${cpu.stepping}`].filter(Boolean).join(' · ')} />
<P k="Microcode" v={cpu.microcode} mono />
<P k="Virt" v={cpu.virtualization} />
</div>
{(cpu.caches ?? []).length > 0 && (
<div className="props" style={{ marginTop: 8 }}>
{(cpu.caches ?? []).map((c) => <P key={c.level} k={c.level} v={c.size} />)}
</div>
)}
{(cpu.flags ?? []).length > 0 && (
<div className="chips" style={{ marginTop: 8 }}>{(cpu.flags ?? []).map((f) => <span className="badge" key={f}>{f}</span>)}</div>
)}
</section>
<section className="card">
<h2>Memory</h2>
<div className="props">
<P k="Installed" v={mem.installed ? fmtBytes(mem.installed) : ''} />
<P k="Usable" v={fmtBytes(mem.total)} />
<P k="Board takes" v={mem.max_capacity ? fmtBytes(mem.max_capacity) : ''} />
<P k="ECC" v={mem.ecc} />
</div>
<div style={{ marginTop: 8 }}>
{(mem.modules ?? []).map((m, i) => <Module key={m.locator + i} m={m} />)}
{(mem.modules ?? []).length === 0 && <div className="empty">The firmware doesn't say what is in the slots.</div>}
</div>
</section>
<section className="card">
<h2>Drives</h2>
{drives.map((d) => <Drive key={d.name} d={d} />)}
{drives.length === 0 && <div className="empty">No drives found.</div>}
</section>
<section className="card">
<h2>Graphics</h2>
{gpus.map((g, i) => <Gpu key={(g.slot ?? '') + i} g={g} />)}
{gpus.length === 0 && <div className="empty">No display adapter found.</div>}
</section>
<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="Serial" v={sys.serial} mono />
<P k="Kernel" v={sys.kernel} mono />
<P k="Arch" v={sys.arch} />
<P k="Virtual" v={sys.virt} />
</div>
</section>
</div>
</div>
)
}
+6 -3
View File
@@ -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: #/<machine|hub>/<view>. Back and
@@ -296,6 +298,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'jobs' && <Jobs />}
{view === 'updates' && <Updates />}
{view === 'disks' && <Disks />}
{view === 'hardware' && <Hardware />}
{view === 'zfs' && <ZFS />}
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
{termView && <TermTabs terms={ordered} active={active} creating={creating}
@@ -323,7 +326,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{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 : ''}</span>
<span className="muted">{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 : ''}</span>
</footer>
</div>
)
+19
View File
@@ -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<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
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<TokenInfo[]>('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<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
docker: () => req<DockerInfo>('GET', '/api/docker'),
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('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'}`),