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
+17
View File
@@ -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 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 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. 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`, - **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
`internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`; `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; reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID;
+13
View File
@@ -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"))
}
+4 -1
View File
@@ -33,6 +33,7 @@ import (
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/hardware"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/nodes" "hamlab/internal/nodes"
"hamlab/internal/priv" "hamlab/internal/priv"
@@ -67,6 +68,7 @@ type Server struct {
tasks *tasks.Manager tasks *tasks.Manager
updates *updates.Manager updates *updates.Manager
storage *storage.Manager storage *storage.Manager
hardware *hardware.Manager
space *space.Manager space *space.Manager
update updateState update updateState
events eventLog 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 { 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, 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() self, err := os.Executable()
if err != nil { if err != nil {
self = "hamlab" 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/rename", s.auth(s.filesRename))
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete)) m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod)) 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", s.auth(s.storageOverview))
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart)) m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest)) m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
+742
View File
@@ -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",
"<out of spec>", "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)
}
+103
View File
@@ -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)
}
}
+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 Jobs from './Jobs'
import Updates from './Updates' import Updates from './Updates'
import Disks from './Disks' import Disks from './Disks'
import Hardware from './Hardware'
import ZFS from './ZFS' import ZFS from './ZFS'
import KVM from './KVM' import KVM from './KVM'
import Files from './Files' import Files from './Files'
@@ -57,6 +58,7 @@ const GROUPS: Group[] = [
{ id: 'services', label: 'Services', icon: '◈' }, { id: 'services', label: 'Services', icon: '◈' },
{ id: 'jobs', label: 'Jobs', icon: ClockIcon }, { id: 'jobs', label: 'Jobs', icon: ClockIcon },
{ id: 'disks', label: 'Disks', icon: '▤' }, { id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
{ id: 'files', label: 'Files', icon: FolderIcon }, { id: 'files', label: 'Files', icon: FolderIcon },
{ id: 'usage', label: 'Usage', icon: '▧' }, { id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' }, { id: 'zfs', label: 'ZFS', icon: '⛁' },
@@ -70,8 +72,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' }, { id: 'settings', label: 'Settings', icon: '⚙' },
] ]
type 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', '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'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and // 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 === 'jobs' && <Jobs />}
{view === 'updates' && <Updates />} {view === 'updates' && <Updates />}
{view === 'disks' && <Disks />} {view === 'disks' && <Disks />}
{view === 'hardware' && <Hardware />}
{view === 'zfs' && <ZFS />} {view === 'zfs' && <ZFS />}
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />} {view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
{termView && <TermTabs terms={ordered} active={active} creating={creating} {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>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <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> </footer>
</div> </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 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 } 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 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 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 } 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 }), 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), 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), 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'), docker: () => req<DockerInfo>('GET', '/api/docker'),
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`), 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'}`), dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),