Author SHA1 Message Date
Mitchell-JohnsonandClaude Opus 5 2e4f3a7fe6 A drive in a pool says which pool, not that nothing is on it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:11:39 -07:00
Mitchell-JohnsonandClaude Opus 5 e0625be684 The disks box is drives; the net box lets you pick the interface
Disks: one row per physical drive with its model, size, temperature and IO, and a usage
bar built from the filesystems the sampler can attribute to it. Attribution is new — a
mount's source device is resolved through sysfs to the drive behind it, following one
layer of device-mapper or md and folding NVMe namespaces the way the sampler does. A pool
member gets no usage bar, because one drive's share of a raidz pool is not a real number.

Net: every interface that is up is listed and tappable, defaulting to the one that has
actually carried traffic rather than whichever the kernel listed first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:09:39 -07:00
Mitchell-JohnsonandClaude Opus 5 bf0bdd54af The disks box is this machine's own storage, not a network share
core mounts grid's tank over CIFS, and it was listed beside root and efi as though it
were a disk in the box. Filesystems whose storage is on another machine are now marked
remote — a label on the fs sensors, so the sensor stays for anyone who wants it — and the
Processes tab's disks box leaves them out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 23:16:17 -07:00
Mitchell-JohnsonandClaude Opus 5 9bf60deffc Retire the Dashboard tab; Processes is the front page
The app repeated itself. The module list, /api/prefs/dashboard, applyPrefs and the
per-user dashboard blob are gone. Widgets stays and now says plainly that it is for
dashboards outside Ham Lab.

This also closes a bug: applyPrefs set the sampling rate from intervalMs inside the
dashboard layout, and reset it to the default when that field was absent — so with
per-user layouts, one account saving a dashboard could reset the sampling rate for the
whole machine. The sampler now has one source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 23:03:49 -07:00
16 changed files with 199 additions and 603 deletions
+17 -12
View File
@@ -259,6 +259,23 @@ something long in one, switch away and back, refresh — it's all still there.
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts` id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
Top sat rather than falling to the bottom. Top sat rather than falling to the bottom.
- **Dashboard retired** — **2026-09-23**, at Mitchell's instruction, the same reasoning that
merged Top into Processes: the app repeated itself. The customisable module list is gone
(`Dashboard.tsx` deleted), and with it `/api/prefs/dashboard`, `applyPrefs`, and the
per-user `dashboard` blob in `state.json`. The Processes tab already shows those numbers
on one screen. `fmtBytes`/`fmtRate` moved to `fmt.ts` (Disks, Files and Space use them);
`MONITOR_HISTORY` and `seriesOf` went with the page. The default view after login, after
switching machines, and when a hub-only page is left behind, is now **Processes**. A saved
rail order naming `dashboard` degrades quietly — `orderBy` drops ids with no page.
**Widgets stays and says what it is for**: a notice at the top explains these are for
dashboards *outside* Ham Lab — Glance, Home Assistant, a wall display — served from
`/embed/widget` with a read-only sensor token, not a second copy of the console's screens.
**It also closed a real bug.** `applyPrefs` set the machine's sampling rate from
`intervalMs` inside the dashboard layout, and its `else` branch reset the sampler to the
default whenever that field was absent. Since layouts became per-user, one account saving
a dashboard could silently reset the sampling rate for the whole machine, overriding the
fleet refresh rate set in Settings. The sampler now has one source: `refreshMs` in the UI
prefs, which the hub fans out, remembered per machine as `sampler_ms`.
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
@@ -531,18 +548,6 @@ something long in one, switch away and back, refresh — it's all still there.
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation `internal/term`); snapshots and clones; and what it does on a host with no virtualisation
at all, which is every host here today. Worth scoping as its own plan before starting. at all, which is every host here today. Worth scoping as its own plan before starting.
- **Deprecate the Dashboard tab** — asked for 2026-09-23, not started. Mitchell: it is nice
but the app repeats itself — the same reasoning that merged Top into Processes the same
day. Dashboard is the customisable module list (CPU, memory, load, uptime, temps, fans,
network, disks, filesystems, UPS) plus custom sensor widgets; the Processes tab shows
most of the same numbers on one screen already. **Widgets stays**, reframed: its page should say plainly that the
widgets there are for **external dashboards** (Glance, Home Assistant, a wall display)
via `/embed/widget` and read-only sensor tokens, not a second copy of this console's own
screens. Care needed on the way out: the dashboard layout is per-user in `state.json`
(`users.<name>.dashboard`) and carries `intervalMs`, which the server reads in
`applyPrefs` to set the sampler — that wiring has to move or the sampling rate loses its
source. `dashboard` is also the default view after login and after switching machines.
- **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started. - **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started.
General polish and ease of use. The **Deprecate copyparty** General polish and ease of use. The **Deprecate copyparty**
item names the heavier pieces (multi-select and bulk actions, folder download, resumable item names the heavier pieces (multi-select and bulk actions, folder download, resumable
+4 -58
View File
@@ -102,18 +102,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
s.sensors.Add(newStatusSensors(s)) s.sensors.Add(newStatusSensors(s))
s.alerts = newAlertEngine(s) s.alerts = newAlertEngine(s)
// The saved dashboard layout carries the sampling interval; apply it on boot. // The saved dashboard layout carries the sampling interval; apply it on boot.
var raw json.RawMessage
var histSec int var histSec int
st.View(func(x *store.State) { st.View(func(x *store.State) { histSec = x.HistorySec })
raw, histSec = x.Dashboard, x.HistorySec
if len(raw) == 0 {
for _, u := range x.Users {
raw = u.Dashboard
break
}
}
})
s.applyPrefs(raw)
if histSec > 0 { if histSec > 0 {
col.SetWindow(time.Duration(histSec) * time.Second) col.SetWindow(time.Duration(histSec) * time.Second)
} }
@@ -289,8 +279,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler)) m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow)) m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow)) m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
m.HandleFunc("/", s.static) m.HandleFunc("/", s.static)
s.mux = m s.mux = m
@@ -930,15 +918,11 @@ func (s *Server) migrateUsers() {
st.Sessions[tok] = ses st.Sessions[tok] = ses
} }
} }
if len(st.UI) > 0 || len(st.Dashboard) > 0 { if len(st.UI) > 0 {
u := st.UserState(first) if u := st.UserState(first); len(u.UI) == 0 {
if len(u.UI) == 0 {
u.UI = st.UI u.UI = st.UI
} }
if len(u.Dashboard) == 0 { st.UI = nil
u.Dashboard = st.Dashboard
}
st.UI, st.Dashboard = nil, nil
} }
}) })
} }
@@ -2282,44 +2266,6 @@ func (s *Server) pushHistoryWindow(seconds int) {
} }
} }
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
func (s *Server) getDashPrefs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
jsonOK(w, 200, s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.Dashboard }))
}
func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' {
jsonErr(w, 400, "expected a JSON object")
return
}
if user := actorOf(r).User; user != "" {
if err := s.store.Update(func(st *store.State) { st.UserState(user).Dashboard = json.RawMessage(b) }); err != nil {
jsonErr(w, 500, err.Error())
return
}
}
s.applyPrefs(b)
w.WriteHeader(204)
}
// applyPrefs picks the few layout fields the server acts on (just the interval).
func (s *Server) applyPrefs(raw []byte) {
var p struct {
IntervalMs int `json:"intervalMs"`
}
if len(raw) == 0 || json.Unmarshal(raw, &p) != nil {
return
}
if p.IntervalMs > 0 {
s.metrics.SetInterval(time.Duration(p.IntervalMs) * time.Millisecond)
} else {
s.metrics.SetInterval(metrics.DefaultInterval)
}
}
// ---- terminals ---- // ---- terminals ----
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
+60 -2
View File
@@ -90,6 +90,15 @@ type FS struct {
Type string `json:"type"` Type string `json:"type"`
Size uint64 `json:"size"` Size uint64 `json:"size"`
Used uint64 `json:"used"` Used uint64 `json:"used"`
// Remote is set when the filesystem lives on another machine — a CIFS or NFS share,
// sshfs, and so on. Its bytes are somebody else's disk, so a panel about this machine's
// hardware leaves it out, while the sensor stays for anyone who does want it.
Remote bool `json:"remote,omitempty"`
// Disk is the physical drive these bytes are on ("nvme0", "sda"), named as the sampler
// names it; empty when that cannot be
// said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and
// a remote mount is on none of them.
Disk string `json:"disk,omitempty"`
} }
type UPS struct { type UPS struct {
@@ -766,6 +775,15 @@ func foldNVMe(devs []Disk) []Disk {
// Kernel/virtual filesystems that aren't storage. Anything else that's mounted is // Kernel/virtual filesystems that aren't storage. Anything else that's mounted is
// shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change. // shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change.
// remoteFS is every filesystem type whose storage is not in this machine.
var remoteFS = map[string]bool{
"cifs": true, "smb3": true, "smbfs": true, "nfs": true, "nfs4": true, "afs": true,
"ceph": true, "glusterfs": true, "9p": true, "afpfs": true, "davfs": true,
"fuse.sshfs": true, "sshfs": true, "fuse.rclone": true, "fuse.s3fs": true,
"fuse.davfs2": true, "fuse.curlftpfs": true, "fuse.gcsfuse": true, "beegfs": true,
"lustre": true, "orangefs": true, "pvfs2": true, "coda": true, "ncpfs": true,
}
var pseudoFS = map[string]bool{ var pseudoFS = map[string]bool{
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true, "proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true,
"cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true, "cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true,
@@ -812,7 +830,7 @@ func filesystems() []FS {
return return
} }
bs := uint64(st.Bsize) bs := uint64(st.Bsize)
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs} done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0])}
}() }()
select { select {
case r := <-done: case r := <-done:
@@ -820,12 +838,52 @@ func filesystems() []FS {
out = append(out, r) out = append(out, r)
} }
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
out = append(out, FS{Mount: mount, Type: fs[2]}) // unreachable: size 0 signals it out = append(out, FS{Mount: mount, Type: fs[2], Remote: remoteFS[fs[2]]}) // unreachable: size 0 signals it
} }
} }
return out return out
} }
// diskOf names the physical drive a mount's source device sits on — "/dev/nvme0n1p2" is on
// "nvme0n1". It follows one layer of device-mapper or md (LUKS on a partition, say) but
// gives up when a thing spans several drives, because then no single drive is the answer.
func diskOf(source string) string {
if !strings.HasPrefix(source, "/dev/") {
return "" // a ZFS pool, a network share, a tmpfs: not a block device at all
}
d := parentDisk(strings.TrimPrefix(source, "/dev/"), 0)
// the sampler reports one drive per NVMe controller, so name it the same way
if m := nsRe.FindStringSubmatch(d); m != nil {
return m[1]
}
return d
}
func parentDisk(name string, depth int) string {
if name == "" || depth > 4 || strings.Contains(name, "/") {
return ""
}
base := "/sys/class/block/" + name
if _, err := os.Stat(base); err != nil {
return ""
}
// a partition: its parent directory in sysfs is the whole drive
if _, err := os.Stat(base + "/partition"); err == nil {
if target, err := os.Readlink(base); err == nil {
return filepath.Base(filepath.Dir(target))
}
return ""
}
// device-mapper or md: one drive underneath is an answer, several is not
if ents, err := os.ReadDir(base + "/slaves"); err == nil && len(ents) > 0 {
if len(ents) > 1 {
return ""
}
return parentDisk(ents[0].Name(), depth+1)
}
return name // a whole drive, used without partitioning
}
// ---- UPS ---- // ---- UPS ----
func runOut(args ...string) (string, bool) { func runOut(args ...string) (string, bool) {
+12 -3
View File
@@ -117,9 +117,18 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
for _, x := range s.FS { for _, x := range s.FS {
p := "fs." + sensors.Seg(x.Mount) p := "fs." + sensors.Seg(x.Mount)
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, "mount", x.Mount, "fstype", x.Type) // remote says the bytes are on another machine (a CIFS or NFS share), so a view of
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), "mount", x.Mount, "fstype", x.Type) // this machine's hardware can leave it out without losing the sensor
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, "mount", x.Mount, "fstype", x.Type) lbl := []string{"mount", x.Mount, "fstype", x.Type}
if x.Remote {
lbl = append(lbl, "remote", "1")
}
if x.Disk != "" {
lbl = append(lbl, "disk", x.Disk)
}
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
} }
if u := s.UPS; u != nil { if u := s.UPS; u != nil {
+2 -4
View File
@@ -41,7 +41,6 @@ func (s *Session) UnmarshalJSON(b []byte) error {
// UserState is what follows an account rather than the machine: how the console looks // UserState is what follows an account rather than the machine: how the console looks
// to that person and how their dashboard is laid out. // to that person and how their dashboard is laid out.
type UserState struct { type UserState struct {
Dashboard json.RawMessage `json:"dashboard,omitempty"` // layout prefs, opaque to the server
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
} }
@@ -49,9 +48,8 @@ type State struct {
Terminals []Terminal `json:"terminals"` Terminals []Terminal `json:"terminals"`
Sessions map[string]Session `json:"sessions"` // token -> session Sessions map[string]Session `json:"sessions"` // token -> session
Users map[string]*UserState `json:"users,omitempty"` Users map[string]*UserState `json:"users,omitempty"`
// Dashboard and UI are the pre-users machine-wide prefs; on first boot with users they // UI is the pre-users machine-wide look; on first boot with users it moves to the first
// move to the first account. Kept so an old state file still reads. // account. Kept so an old state file still reads.
Dashboard json.RawMessage `json:"dashboard,omitempty"`
UI json.RawMessage `json:"ui,omitempty"` UI json.RawMessage `json:"ui,omitempty"`
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go) Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
-474
View File
@@ -1,474 +0,0 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline'
import { Loading, Spinner, askYes, toast } from './ui'
import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
import WidgetPicker from './WidgetPicker'
const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)'
// ---- module registry: the customizable blocks, in default order ----
type Kind = 'tile' | 'card'
interface Module { id: string; label: string; kind: Kind }
const MODULES: Module[] = [
{ id: 'cpu', label: 'CPU', kind: 'tile' },
{ id: 'mem', label: 'Memory', kind: 'tile' },
{ id: 'load', label: 'Load', kind: 'tile' },
{ id: 'uptime', label: 'Uptime', kind: 'tile' },
{ id: 'temps', label: 'Temperatures', kind: 'card' },
{ id: 'fans', label: 'Fans', kind: 'card' },
{ id: 'net', label: 'Network', kind: 'card' },
{ id: 'disks', label: 'Disks', kind: 'card' },
{ id: 'fs', label: 'Filesystems', kind: 'card' },
{ id: 'ups', label: 'UPS', kind: 'card' },
]
const byId = (id: string) => MODULES.find((m) => m.id === id)
const DEFAULT_INTERVAL = 2000
const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
function normalize(raw: DashPrefs): Layout {
// Saved prefs are whatever some client last wrote; a wrong shape must not take the page down.
const list = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const p = { order: list<string>(raw?.order), hidden: list<string>(raw?.hidden), hiddenItems: list<string>(raw?.hiddenItems), widgets: list<WidgetSpec>(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined }
const known = new Set(MODULES.map((m) => m.id))
const order = (p.order ?? []).filter((id) => known.has(id))
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv,
widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') }
}
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets })
// ---- formatting ----
// A live value either starts a new point or is averaged into the one being built — the same
// rule the machine itself uses to keep its history, so the tail of a six-hour graph isn't a
// minute of 2-second samples crushed against the right edge. n is how many values this point
// has had, including this one.
function pushPoint(arr: number[], v: number, fresh: boolean, n: number) {
if (!fresh && arr.length) { arr[arr.length - 1] += (v - arr[arr.length - 1]) / n; return }
arr.push(v)
if (arr.length > MAX) arr.shift()
}
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
export function fmtBytes(b: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let i = 0
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
}
export function fmtRate(bps: number): string {
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
let i = 0
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
}
function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
}
const pct = (v: number) => `${Math.round(v)}%`
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' { return frac >= bad ? 'bad' : frac >= warn ? 'warn' : '' }
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest.
function collapseTemps(temps: Temp[]): (Temp & { key: string })[] {
const byChip = new Map<string, Temp[]>()
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t)
const out: (Temp & { key: string })[] = []
for (const [chip, list] of byChip) {
if (list.length <= 8) { out.push(...list.map((t) => ({ ...t, key: `${t.chip}/${t.label}` }))); continue }
const hot = list.reduce((a, b) => (b.c > a.c ? b : a))
out.push({ ...hot, label: `${list.length} sensors · hottest ${hot.label}`, chip, key: `${chip}/*` })
}
return out
}
// ---- small building blocks ----
function Meter({ frac, cls }: { frac: number; cls?: string }) {
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Legend({ items }: { items: { name: string; color: string }[] }) {
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
}
interface Edit {
on: boolean
hideItem: (key: string) => void
unhideItem: (key: string) => void
hiddenItems: Set<string>
}
// In customize mode every row gets a ✕; hidden rows come back as chips under the module.
function HideBtn({ k, edit }: { k: string; edit: Edit }) {
return edit.on ? <button className="hide-item" title="hide" onClick={() => edit.hideItem(k)}>✕</button> : null
}
function HiddenChips({ prefix, edit, label }: { prefix: string; edit: Edit; label: (k: string) => string }) {
if (!edit.on) return null
const keys = [...edit.hiddenItems].filter((k) => k.startsWith(prefix + ':'))
if (keys.length === 0) return null
return <div className="chips inline">{keys.map((k) => <button key={k} className="chip" title="show again" onClick={() => edit.unhideItem(k)}>+ {label(k.slice(prefix.length + 1))}</button>)}</div>
}
// ---- module bodies ----
interface Ctx { s: Sample; h: Series | null; edit: Edit }
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
}
function body(id: string, { s, h, edit }: Ctx): ReactNode {
const shown = (key: string) => !edit.hiddenItems.has(key)
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
switch (id) {
case 'cpu':
return (
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length === 0 ? undefined : s.cores.length <= 16 ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}>
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
</Tile>
)
case 'mem':
return (
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
</Tile>
)
case 'load':
return <Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
case 'uptime':
return <Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
case 'temps': {
const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`))
return (
<>
<h2>Temperatures</h2>
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
{rows.map((t) => {
const limit = t.crit || t.max || 100
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
return (
<div className="row" key={t.key}>
<span className="k">{t.label}<small>{t.chip}</small></span>
<Meter frac={t.c / limit} cls={cls} />
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}<HideBtn k={`temp:${t.key}`} edit={edit} /></span>
</div>
)
})}
<HiddenChips prefix="temp" edit={edit} label={(k) => k.replace('/*', '')} />
</>
)
}
case 'fans': {
const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`))
return (
<>
<h2>Fans</h2>
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
{rows.map((f) => (
<div className="row" key={`${f.chip}/${f.label}`}>
<span className="k">{f.label}<small>{f.chip}</small></span>
<span />
<span className="num">{f.rpm} rpm<HideBtn k={`fan:${f.chip}/${f.label}`} edit={edit} /></span>
</div>
))}
<HiddenChips prefix="fan" edit={edit} label={(k) => k} />
</>
)
}
case 'net':
return (
<>
<h2>Network</h2>
{s.net.filter((x) => shown(`net:${x.iface}`)).map((x) => (
<div className="sub-card" key={x.iface}>
<div className="sub-head">
<span className={'dot ' + (x.up ? 'ok' : '')} />
<span>{x.iface}</span>
{!x.up && <span className="muted small">down</span>}
<span className="spacer" />
{x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
<HideBtn k={`net:${x.iface}`} edit={edit} />
</div>
{x.up && h?.net[x.iface] && (
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
)}
{x.up && <div className="legend">
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
</div>}
</div>
))}
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
</>
)
case 'disks':
return (
<>
<h2>Disks</h2>
{s.disks.length === 0 && <div className="empty">No block devices found.</div>}
{s.disks.filter((d) => shown(`disk:${d.name}`)).map((d) => (
<div className="sub-card" key={d.name}>
<div className="sub-head">
<span>{d.name}</span>
<span className="model">{d.model}</span>
<span className="spacer" />
<span className="num">{fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
<HideBtn k={`disk:${d.name}`} edit={edit} />
</div>
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
{h?.disk[d.name] && (
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
)}
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
</div>
))}
<HiddenChips prefix="disk" edit={edit} label={(k) => k} />
</>
)
case 'fs':
return (
<>
<h2>Filesystems</h2>
{s.fs.filter((f) => shown(`fs:${f.mount}`)).map((f) => (
<div className="row" key={f.mount}>
<span className="k">{f.mount}<small>{f.type}</small></span>
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}<HideBtn k={`fs:${f.mount}`} edit={edit} /></span>
</div>
))}
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
</>
)
case 'ups':
return (
<>
<h2>UPS</h2>
{!s.ups && <div className="empty">No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).</div>}
{s.ups && (
<>
<div className="sub-head">
<span className={'dot ' + (s.ups.on_battery ? 'bad' : 'ok')} />
<span>{s.ups.state} · {s.ups.source}</span>
<span className="spacer" />
<span className="model">{s.ups.model} · {s.ups.driver}</span>
</div>
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
</>
)}
</>
)
}
return null
}
// ---- the dashboard ----
export default function Dashboard() {
const [latest, setLatest] = useState<Sample | null>(null)
const [err, setErr] = useState('')
const [layout, setLayout] = useState<Layout>(() => normalize({}))
const [editing, setEditing] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('')
const savedTimer = useRef<number | undefined>(undefined)
const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(undefined)
const refreshMs = useRefreshMs()
const historySec = useHistorySec()
// Persist layout changes server-side (debounced) so every device sees the same dashboard.
const update = useCallback((fn: (l: Layout) => Layout) => {
setLayout((prev) => {
const next = fn(prev)
clearTimeout(saveTimer.current)
setSaving('saving')
saveTimer.current = window.setTimeout(async () => {
try {
await api.saveDashPrefs(toPrefs(next))
setSaving('saved')
clearTimeout(savedTimer.current)
savedTimer.current = window.setTimeout(() => setSaving(''), 1500)
} catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}, 300)
return next
})
}, [])
useEffect(() => {
let stop = false
api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout'))
return () => { stop = true }
}, [])
// Everything on this page comes from the sensor API: the catalog for names, labels and
// thresholds; one batch of histories to seed the curves; one stream for the values.
const catalog = useRef<Catalog>(new Map())
const refetching = useRef(false)
const binN = useRef(1) // values averaged into the point currently being built
const lastPush = useRef(0) // when that point was started, by this browser's clock
const stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
const seed = useCallback(async () => {
try {
catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current)
lastPush.current = Date.now(); binN.current = 1
setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { seed() }, [seed, stock.resumed]) // on load, and again after the page was hidden: the curves have a gap to refill
useEffect(() => {
if (!stock.data || catalog.current.size === 0) return
const v: Values = mergeValues(stock.data)
if (stale(v, catalog.current) && !refetching.current) { // something appeared (a disk, an interface): learn its labels
refetching.current = true
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
}
const s = sampleOf(v, catalog.current)
const h = hist.current
if (h) {
// The stride is timed on this clock alone (lastPush), never against the history's own
// timestamps: those are the machine's, and a viewer whose clock runs minutes slow would
// otherwise wait those minutes before its curves gained a point.
const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs)
const fresh = np.fresh
lastPush.current = np.start
binN.current = fresh ? 1 : binN.current + 1
if (fresh) pushPoint(h.t, s.t, true, 1)
const n = h.t.length
// a curve that has no point in this one yet starts one, whatever the others are doing:
// an interface or disk that appeared mid-point must still line up with the time axis
const push = (arr: number[], x: number) => pushPoint(arr, x, fresh || arr.length < n, binN.current)
push(h.cpu, s.cpu); push(h.mem, s.mem.used)
for (const x of s.net) {
const c = (h.net[x.iface] ??= { rx: [], tx: [] })
pad(c.rx, n - 1); pad(c.tx, n - 1); push(c.rx, x.rx_bps); push(c.tx, x.tx_bps)
}
for (const d of s.disks) {
const c = (h.disk[d.name] ??= { r: [], w: [], u: [] })
pad(c.r, n - 1); pad(c.w, n - 1); pad(c.u, n - 1); push(c.r, d.read_bps); push(c.w, d.write_bps); push(c.u, d.util_pct)
}
for (const t of s.temps) {
const c = (h.temp[`${t.chip}/${t.label}`] ??= [])
pad(c, n - 1); push(c, t.c)
}
}
setLatest(s)
}, [stock.data, historySec, refreshMs])
useEffect(() => { if (stock.error && !latest) setErr(stock.error) }, [stock.error, latest])
// The sampler on this machine follows the global rate (clamped to what it supports).
useEffect(() => {
if (!loaded) return
const want = clampSampler(refreshMs)
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
}, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
const [picking, setPicking] = useState(false)
const [editingWidget, setEditingWidget] = useState<WidgetSpec | null>(null)
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
const edit: Edit = {
on: editing,
hiddenItems: layout.hiddenItems,
hideItem: (k) => update((l) => ({ ...l, hiddenItems: new Set([...l.hiddenItems, k]) })),
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
}
const ctx: Ctx = { s: latest, h: hist.current, edit }
// Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
const move = (id: string, dir: -1 | 1) => update((l) => {
const kind = byId(id)!.kind
const same = l.order.filter((x) => byId(x)?.kind === kind && !l.hidden.has(x))
const i = same.indexOf(id), j = i + dir
if (j < 0 || j >= same.length) return l
const order = [...l.order]
const a = order.indexOf(id), b = order.indexOf(same[j])
;[order[a], order[b]] = [order[b], order[a]]
return { ...l, order }
})
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
const reset = async () => { if (await askYes(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
const tools = (id: string, kind: Kind) => {
if (!editing) return null
const same = visible(kind), i = same.indexOf(id)
return (
<div className="mod-tools">
<button title="move up" disabled={i === 0} onClick={() => move(id, -1)}>▲</button>
<button title="move down" disabled={i === same.length - 1} onClick={() => move(id, 1)}>▼</button>
<button title="hide module" onClick={() => hideMod(id)}>hide</button>
</div>
)
}
return (
<div className={'dash' + (editing ? ' editing' : '')}>
<div className="dash-bar">
{err && <span className="error">{err}</span>}
{editing ? <span className="hint">last {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
: <span className="hint">last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<span className="spacer" />
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
{saving === 'saved' && <span className="saved">saved ✓</span>}
{editing && <button className="btn" onClick={reset}>Reset</button>}
<button className={'btn' + (editing ? ' on' : '')} disabled={!loaded} title={loaded ? '' : 'layout not loaded'} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
</div>
{visible('tile').length > 0 && (
<div className="kpis">
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
</div>
)}
{(layout.widgets.length > 0 || editing) && (
<div className="kpis widgets">
{layout.widgets.map((w, i) => (
<div className={'tile w-' + w.type} key={w.id}>
{editing && (
<div className="mod-tools">
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
<button title="change its sensor, type or title" onClick={() => setEditingWidget(w)}>edit</button>
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
</div>
)}
<Widget spec={w} data={live.data ? live.data[w.id] : undefined} />
</div>
))}
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering: {live.error}.</div>}
</div>
)}
{editingWidget && <WidgetPicker initial={editingWidget} onClose={() => setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />}
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
{visible('card').length > 0 && (
<div className="cards">
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
</div>
)}
{editing && hiddenMods.length > 0 && (
<div className="card">
<h2>Hidden modules</h2>
<div className="chips">{hiddenMods.map((id) => <button key={id} className="chip" onClick={() => showMod(id)}>+ {byId(id)!.label}</button>)}</div>
</div>
)}
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
import { fmtBytes, fmtRate } from './Dashboard' import { fmtBytes, fmtRate } from './fmt'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import { Btn, Loading, Spinner, askText, toast } from './ui' import { Btn, Loading, Spinner, askText, toast } from './ui'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
+1 -1
View File
@@ -1,6 +1,6 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react' import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { api, FileEntry, FileInfo } from './api' import { api, FileEntry, FileInfo } from './api'
import { fmtBytes } from './Dashboard' import { fmtBytes } from './fmt'
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui' import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
+7
View File
@@ -57,6 +57,13 @@ export default function Gallery() {
return ( return (
<div className="dash gallery"> <div className="dash gallery">
<div className="notice" style={{ marginBottom: 10 }}>
These widgets are for <b>dashboards outside Ham Lab</b> — Glance, Home Assistant, a wall
display. Each one is a page at <span className="mono">/embed/widget</span> you put in an
iframe, reading this machine through a <b>read-only sensor token</b> (Settings → Access
tokens). Ham Lab shows you these numbers on its own pages; this tab is for handing them
to something else.
</div>
<div className="dash-bar"> <div className="dash-bar">
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span> <span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" /> <input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
+6 -9
View File
@@ -4,7 +4,6 @@ import Rail, { Group } from './Rail'
import TerminalPane, { TermHandle } from './TerminalPane' import TerminalPane, { TermHandle } from './TerminalPane'
import TermTabs from './TermTabs' import TermTabs from './TermTabs'
import KeyBar from './KeyBar' import KeyBar from './KeyBar'
import Dashboard from './Dashboard'
import Gallery from './Gallery' import Gallery from './Gallery'
import Docker from './Docker' import Docker from './Docker'
import Alerts from './Alerts' import Alerts from './Alerts'
@@ -51,7 +50,6 @@ const BellIcon = (
) )
const GROUPS: Group[] = [ const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
{ id: 'procs', label: 'Processes', icon: '▚' }, { id: 'procs', label: 'Processes', icon: '▚' },
{ id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' }, { id: 'services', label: 'Services', icon: '◈' },
@@ -68,8 +66,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' }, { id: 'settings', label: 'Settings', icon: '⚙' },
] ]
type View = 'terminal' | 'dashboard' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings' type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings'] const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', '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
@@ -102,7 +100,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
const listRate = useRefreshMs(LIST_FLOOR) const listRate = useRefreshMs(LIST_FLOOR)
const [terms, setTerms] = useState<Term[]>([]) const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'dashboard') const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
const [drawer, setDrawer] = useState(false) const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true) const [online, setOnline] = useState(true)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@@ -142,7 +140,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
// the machine is changing: the URL sets the machine and the page in one go, and deciding from // the machine is changing: the URL sets the machine and the page in one go, and deciding from
// inside the machine change read the page we were leaving — so a link from Settings to a // inside the machine change read the page we were leaving — so a link from Settings to a
// machine's Disks arrived at its Dashboard. // machine's Disks arrived at its Dashboard.
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('dashboard') }, [machine, view]) useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host]) useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
// The tab's title says where you are, so history entries and open tabs can be told apart. // The tab's title says where you are, so history entries and open tabs can be told apart.
@@ -265,8 +263,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'settings' && <Settings me={hubMe} />} {view === 'settings' && <Settings me={hubMe} />}
{view === 'users' && <Users me={hubMe} />} {view === 'users' && <Users me={hubMe} />}
{view === 'space' && <Space key={machine ?? ''} />} {view === 'space' && <Space key={machine ?? ''} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />} {view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
{view === 'dashboard' && <Dashboard />}
{view === 'widgets' && <Gallery />} {view === 'widgets' && <Gallery />}
{view === 'docker' && <Docker />} {view === 'docker' && <Docker />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
@@ -301,7 +298,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 === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : 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 === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+1 -1
View File
@@ -4,7 +4,7 @@
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is. // file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, SpaceFile, SpaceTarget, SpaceTree } from './api' import { api, SpaceFile, SpaceTarget, SpaceTree } from './api'
import { fmtBytes } from './Dashboard' import { fmtBytes } from './fmt'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui' import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
+54 -13
View File
@@ -250,6 +250,20 @@ export default function Top() {
// the process table, and the CPU model, come from the same place the Processes page reads // the process table, and the CPU model, come from the same place the Processes page reads
const [pt, setPt] = useState<ProcTable | null>(null) const [pt, setPt] = useState<ProcTable | null>(null)
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
const [pools, setPools] = useState<Record<string, string>>({})
useEffect(() => {
let live = true
api.storage().then((r) => {
if (!live) return
const m: Record<string, string> = {}
for (const d of r.devices) for (const part of d.parts) if (part.fstype === 'zfs_member' && part.label) m[d.name] = part.label
setPools(m)
}).catch(() => { /* the rows simply say less */ })
return () => { live = false }
}, [])
const [err, setErr] = useState('') const [err, setErr] = useState('')
useEffect(() => { useEffect(() => {
let stop = false let stop = false
@@ -319,8 +333,12 @@ export default function Top() {
const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached) const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached)
const swap = s?.swap ?? pt?.swap const swap = s?.swap ?? pt?.swap
const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo') const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo')
const iface = net[0] // the one that has actually carried traffic, not whichever the kernel listed first — wg0
const fs = s?.fs ?? [] // and a dormant wlan should not push the real NIC out of the way. Tapping a name switches.
const busiest = net.length ? net.reduce((a, b) => (b.rx_total + b.tx_total > a.rx_total + a.tx_total ? b : a)) : undefined
const iface = net.find((n) => n.iface === pickedIf) ?? busiest
// the disks box is this machine's own storage: a CIFS or NFS share is somebody else's disk
const fs = (s?.fs ?? []).filter((f) => !f.remote)
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0) const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept // the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap)) const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
@@ -378,18 +396,26 @@ export default function Top() {
<Box n={3} name="disks" className="top-disks" right="io"> <Box n={3} name="disks" className="top-disks" right="io">
<div className="t-disks"> <div className="t-disks">
{swap && swap.total > 0 && <Disk name="swap" size={swap.total} used={swap.used} />} {(s?.disks ?? []).map((d) => {
{fs.map((f) => { // what is on this drive: the filesystems the sampler could attribute to it. A ZFS
// which drive is behind a mount isn't in the sample, so only the root filesystem // pool or an md array spans several drives, so nothing is attributed and the row
// gets an IO line, against the machine's largest drive — which is the one it is on // shows what is true of the hardware — how busy it is, and how hot.
const d = f.mount === '/' ? [...(s?.disks ?? [])].sort((a, b) => b.size - a.size)[0] : undefined const on = fs.filter((f) => f.disk === d.name)
return <Disk key={f.mount} name={f.mount === '/' ? 'root' : f.mount.split('/').filter(Boolean).pop() ?? f.mount} size={f.size} used={f.used} io={d ? d.util_pct : undefined} /> const used = on.reduce((a, f) => a + f.used, 0)
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
})} })}
{fs.length === 0 && <div className="t-dim">no filesystems reported</div>} {(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
</div> </div>
</Box> </Box>
<Box n={4} name="net" className="top-net" right={iface ? <span className="t-if">{iface.iface}</span> : undefined}> <Box n={4} name="net" className="top-net" right={net.length ? (
<span className="t-ifs">
{net.map((n) => (
<button key={n.iface} className={'t-if' + (iface && n.iface === iface.iface ? ' on' : '')}
title={`${n.iface} — ${bytes(n.rx_total)} in, ${bytes(n.tx_total)} out`} onClick={() => setPickedIf(n.iface)}>{n.iface}</button>
))}
</span>
) : undefined}>
{iface ? ( {iface ? (
<div className="t-net"> <div className="t-net">
<div className="t-net-graphs"> <div className="t-net-graphs">
@@ -470,14 +496,29 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
) )
}) })
function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) { // One physical drive. `used` is the bytes of the filesystems on it, left out when none can
const upct = size > 0 ? (used / size) * 100 : 0 // be attributed to it — a pool member's share of a pool is not a number worth inventing.
function Disk({ name, note, size, used, io, temp, mounts, pool }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[]; pool?: string }) {
const upct = used !== undefined && size > 0 ? (used / size) * 100 : 0
return ( return (
<div className="t-disk"> <div className="t-disk">
<div className="t-row"><b>{name}</b><span className="spacer" />{bytes(size)}</div> <div className="t-row">
<b>{name}</b>
{note && <span className="t-dim t-model" title={note}>{note}</span>}
<span className="spacer" />
{temp !== undefined && temp > 0 && <span className="t-temp">{Math.round(temp)}°C</span>}
{bytes(size)}
</div>
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>} {io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
{used !== undefined ? (
<>
<div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div> <div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div>
<div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div> <div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div>
{mounts && mounts.length > 0 && <div className="t-row t-sub t-dim t-mounts" title={mounts.join(' ')}>{mounts.join(' ')}</div>}
</>
) : (
<div className="t-row t-sub t-dim">{pool ? `member of ZFS pool ${pool}` : 'nothing mounted from it'}</div>
)}
</div> </div>
) )
} }
+1 -4
View File
@@ -70,7 +70,7 @@ export interface Temp { chip: string; label: string; c: number; max?: number; cr
export interface Fan { chip: string; label: string; rpm: number } export interface Fan { chip: string; label: string; rpm: number }
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number } export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number } export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
export interface FS { mount: string; type: string; size: number; used: number } export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string }
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string } export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number } export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number } export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
@@ -90,7 +90,6 @@ export interface Series {
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched // Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label"). // off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number } export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number }
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] }
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean } export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> } export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
@@ -208,8 +207,6 @@ export const api = {
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`), serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true), historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true),
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true), setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true), login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true),
users: () => req<User[]>('GET', '/api/users', undefined, true), users: () => req<User[]>('GET', '/api/users', undefined, true),
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true), addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
+19
View File
@@ -0,0 +1,19 @@
// Sizes and rates, shown the way the rest of the console shows them. These lived in
// Dashboard.tsx until that page was retired (2026-09-23); Disks, Files and Space all use
// them, so they belong on their own.
// fmtBytes is binary — what a disk or a file actually occupies.
export function fmtBytes(b: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let i = 0
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
}
// fmtRate is decimal — how throughput is quoted everywhere else.
export function fmtRate(bps: number): string {
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
let i = 0
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
}
+2 -18
View File
@@ -1,7 +1,7 @@
// The stock Dashboard modules were written against one Sample object. They now read the // The stock Dashboard modules were written against one Sample object. They now read the
// sensor API like everything else; this file is the seam — it rebuilds that object from // sensor API like everything else; this file is the seam — it rebuilds that object from
// sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds). // sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds).
import { Sample, Series } from './api' import { Sample } from './api'
import { Sensor } from './sensors' import { Sensor } from './sensors'
export type Values = Record<string, number | boolean | string> export type Values = Record<string, number | boolean | string>
@@ -19,7 +19,6 @@ export function mergeValues(parts: unknown[]): Values {
return out return out
} }
// The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature. // The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature.
export const MONITOR_HISTORY = 'cpu.usage,mem.used,net.*.rx,net.*.tx,disk.*.read,disk.*.write,disk.*.util,temp.*'
const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0) const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0)
const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '') const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '')
@@ -52,7 +51,7 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })), fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })), net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })), disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used') })), fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk })),
ups: v['ups.battery'] === undefined ? null : { ups: v['ups.battery'] === undefined ? null : {
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'), driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'), on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
@@ -63,18 +62,3 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
// The modules' history: one shared time axis (the CPU's), every other curve right-aligned // The modules' history: one shared time axis (the CPU's), every other curve right-aligned
// to it and zero-padded on the left where the sensor is younger than the ring. // to it and zero-padded on the left where the sensor is younger than the ring.
export function seriesOf(hist: { id: string; t: number[]; v: number[] }[], cat: Catalog): Series {
const by = new Map(hist.map((h) => [h.id, h]))
const t = by.get('cpu.usage')?.t ?? []
const n = t.length
const fit = (id: string) => { const v = (by.get(id)?.v ?? []).slice(-n); return [...new Array(Math.max(0, n - v.length)).fill(0), ...v] as number[] }
const out: Series = { t: [...t], cpu: fit('cpu.usage'), mem: fit('mem.used'), net: {}, disk: {}, temp: {} }
for (const h of hist) {
const l = cat.get(h.id)?.labels
const stem = h.id.slice(0, h.id.lastIndexOf('.'))
if (h.id.startsWith('net.') && l?.iface) out.net[l.iface] ??= { rx: fit(stem + '.rx'), tx: fit(stem + '.tx') }
else if (h.id.startsWith('disk.') && l?.device) out.disk[l.device] ??= { r: fit(stem + '.read'), w: fit(stem + '.write'), u: fit(stem + '.util') }
else if (h.id.startsWith('temp.') && l?.chip !== undefined) out.temp[`${l.chip}/${l.label}`] = fit(h.id)
}
return out
}
+9
View File
@@ -720,3 +720,12 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; } .space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; }
.space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; } .space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; }
} }
/* the Processes tab's drive rows and NIC switcher */
.t-disk .t-model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 14ch; margin-left: 6px; }
.t-disk .t-temp { margin-right: 8px; color: var(--t-blue, var(--accent2)); }
.t-disk .t-mounts { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.t-ifs { display: inline-flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
.t-ifs .t-if { padding: 0 4px; border-radius: 3px; color: var(--muted); }
.t-ifs .t-if:hover { color: var(--fg); background: var(--bg3); }
.t-ifs .t-if.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); }