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:
co-authored by
Claude Opus 5
parent
09dc846730
commit
776bcb236d
@@ -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;
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user