From eb70daf1299090f6a0836feb55270906c7ba962f Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 17 Sep 2026 13:46:41 -0700 Subject: [PATCH] Dashboard customize mode: reorder/hide modules and individual rows, layout saved server-side (GET/PUT /api/prefs/dashboard) Co-Authored-By: Claude Fable 5.1 --- PLAN.md | 6 +- cmd/hamlab/main.go | 2 +- internal/api/server.go | 34 +++- internal/store/store.go | 3 +- web/src/Dashboard.tsx | 429 +++++++++++++++++++++++++++------------- web/src/api.ts | 6 + web/src/styles.css | 15 ++ 7 files changed, 351 insertions(+), 144 deletions(-) diff --git a/PLAN.md b/PLAN.md index ba39071..04dd39d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -133,7 +133,11 @@ something long in one, switch away and back, refresh — it's all still there. from `/sys` (no lm-sensors), per-interface network rates, per-disk read/write/busy from `/proc/diskstats`, filesystem usage, UPS via `sudo -n pwrstat -status`. Sampled every 2 s into an in-memory ring of 10 min; the UI seeds from - `GET /api/metrics/history` then polls `GET /api/metrics`. Still to do: persistent + `GET /api/metrics/history` then polls `GET /api/metrics`. **Customizable**: a + Customize mode reorders and hides modules (CPU, Memory, Load, Uptime, Temperatures, + Fans, Network, Disks, Filesystems, UPS) and hides individual rows (an interface, a + mount, a disk, a sensor); the layout is stored server-side in `state.json` via + `GET/PUT /api/prefs/dashboard` so every device sees the same dashboard. Still to do: persistent history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges. - **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini / `wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index ea20a4e..6acac33 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -141,7 +141,7 @@ func serve(cfg *config.Config) error { col := metrics.NewCollector() go col.Run(context.Background()) - srv := api.New(cfg, sessions, mgr, col, web.FS(), version) + srv := api.New(cfg, sessions, mgr, col, st, web.FS(), version) hs := &http.Server{ Addr: cfg.Listen, Handler: srv.Handler(), diff --git a/internal/api/server.go b/internal/api/server.go index 18b1944..580da65 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,6 +19,7 @@ import ( "hamlab/internal/auth" "hamlab/internal/config" "hamlab/internal/metrics" + "hamlab/internal/store" "hamlab/internal/term" ) @@ -30,6 +31,7 @@ type Server struct { limiter auth.Limiter mgr *term.Manager metrics *metrics.Collector + store *store.Store version string started time.Time user string @@ -38,9 +40,9 @@ type Server struct { mux *http.ServeMux } -func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, ui fs.FS, version string) *Server { +func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, ui fs.FS, version string) *Server { host, _ := os.Hostname() - s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, ui: ui, version: version, + s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, ui: ui, version: version, started: time.Now(), host: host, user: os.Getenv("USER")} if u, err := user.Current(); err == nil && u.Username != "" { s.user = u.Username @@ -60,6 +62,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach)) m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest)) m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory)) + 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("/", s.static) s.mux = m @@ -185,6 +189,32 @@ func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, s.metrics.History()) } +// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ---- + +func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) { + var raw json.RawMessage + s.store.View(func(st *store.State) { raw = st.Dashboard }) + if len(raw) == 0 { + raw = json.RawMessage("{}") + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(raw) +} + +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 err := s.store.Update(func(st *store.State) { st.Dashboard = json.RawMessage(b) }); err != nil { + jsonErr(w, 500, err.Error()) + return + } + w.WriteHeader(204) +} + // ---- terminals ---- func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/store/store.go b/internal/store/store.go index c905722..62d0a61 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -17,7 +17,8 @@ type Terminal struct { type State struct { Terminals []Terminal `json:"terminals"` - Sessions map[string]time.Time `json:"sessions"` // token -> expiry + Sessions map[string]time.Time `json:"sessions"` // token -> expiry + Dashboard json.RawMessage `json:"dashboard,omitempty"` // UI layout prefs, opaque to the server } type Store struct { diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index fce43ff..6e945f3 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -1,10 +1,39 @@ -import { ReactNode, useEffect, useRef, useState } from 'react' -import { api, Sample, Series, Temp } from './api' +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { api, DashPrefs, Sample, Series, Temp } from './api' import Sparkline from './Sparkline' 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) + +interface Layout { order: string[]; hidden: Set; hiddenItems: Set } + +// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules. +function normalize(p: DashPrefs): Layout { + 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) + return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []) } +} +const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems] }) + +// ---- formatting ---- function push(arr: number[], v: number) { arr.push(v); if (arr.length > MAX) arr.shift() } function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) } @@ -25,55 +54,223 @@ function fmtUptime(s: number): string { 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' : '' -} - -function Meter({ frac, cls }: { frac: number; cls?: string }) { - return
-} - -function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) { - return ( -
-
{label}
-
{value}
- {sub &&
{sub}
} - {children} -
- ) -} - -function Card({ title, children }: { title: string; children: ReactNode }) { - return

{title}

{children}
-} - -function Legend({ items }: { items: { name: string; color: string }[] }) { - return
{items.map((i) => {i.name})}
-} +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[] { +function collapseTemps(temps: Temp[]): (Temp & { key: string })[] { const byChip = new Map() for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t) - const out: Temp[] = [] + const out: (Temp & { key: string })[] = [] for (const [chip, list] of byChip) { - if (list.length <= 8) { out.push(...list); continue } + 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 }) + 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
+} +function Legend({ items }: { items: { name: string; color: string }[] }) { + return
{items.map((i) => {i.name})}
+} + +interface Edit { + on: boolean + hideItem: (key: string) => void + unhideItem: (key: string) => void + hiddenItems: Set +} +// 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 ? : 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
{keys.map((k) => )}
+} + +// ---- 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 <>
{label}
{value}
{sub &&
{sub}
}{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 ( + Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}> + {h && } + + ) + case 'mem': + return ( + + {h && } + + ) + case 'load': + return l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} /> + case 'uptime': + return + + case 'temps': { + const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`)) + return ( + <> +

Temperatures

+ {s.temps.length === 0 &&
No temperature sensors reported by this host.
} + {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 ( +
+ {t.label}{t.chip} + + {t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''} +
+ ) + })} + k.replace('/*', '')} /> + + ) + } + case 'fans': { + const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`)) + return ( + <> +

Fans

+ {s.fans.length === 0 &&
No fan sensors reported by this host.
} + {rows.map((f) => ( +
+ {f.label}{f.chip} + + {f.rpm} rpm +
+ ))} + k} /> + + ) + } + case 'net': + return ( + <> +

Network

+ {s.net.filter((x) => shown(`net:${x.iface}`)).map((x) => ( +
+
+ + {x.iface} + + ↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)} + +
+ {h?.net[x.iface] && ( + + )} +
+ rx · {fmtBytes(x.rx_total)} total + tx · {fmtBytes(x.tx_total)} total +
+
+ ))} + k} /> + + ) + case 'disks': + return ( + <> +

Disks

+ {s.disks.length === 0 &&
No block devices found.
} + {s.disks.filter((d) => shown(`disk:${d.name}`)).map((d) => ( +
+
+ {d.name} + {d.model} + + {fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy + +
+
read {fmtRate(d.read_bps)}write {fmtRate(d.write_bps)}
+ {h?.disk[d.name] && ( + + )} + +
+ ))} + k} /> + + ) + case 'fs': + return ( + <> +

Filesystems

+ {s.fs.filter((f) => shown(`fs:${f.mount}`)).map((f) => ( +
+ {f.mount}{f.type} + {f.size ? : unreachable} + {f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''} +
+ ))} + k} /> + + ) + case 'ups': + return ( + <> +

UPS

+ {!s.ups &&
No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).
} + {s.ups && ( + <> +
+ + {s.ups.state} · {s.ups.source} + + {s.ups.model} · {s.ups.driver} +
+
Battery{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min
+
Load{s.ups.load_w} W · {pct(s.ups.load_pct)}
+
Voltagein {s.ups.in_v} V · out {s.ups.out_v} V
+ {s.ups.last_event &&
Last event{s.ups.last_event}
} + + )} + + ) + } + return null +} + +// ---- the dashboard ---- export default function Dashboard() { const [latest, setLatest] = useState(null) const [err, setErr] = useState('') + const [layout, setLayout] = useState(() => normalize({})) + const [editing, setEditing] = useState(false) const hist = useRef(null) + const saveTimer = useRef(undefined) + + // 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) + saveTimer.current = window.setTimeout(() => { api.saveDashPrefs(toPrefs(next)).catch(() => setErr('could not save layout')) }, 300) + return next + }) + }, []) useEffect(() => { let stop = false let timer: number | undefined + api.dashPrefs().then((p) => { if (!stop) setLayout(normalize(p)) }).catch(() => { /* defaults */ }) const seed = async () => { try { hist.current = await api.metricsHistory(); setLatest(await api.metrics()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } @@ -112,117 +309,71 @@ export default function Dashboard() { }, []) if (!latest) return
{err || '…'}
- const h = hist.current - const s = latest - const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0 + + 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 } + const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(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 = () => update(() => normalize({})) + + const tools = (id: string, kind: Kind) => { + if (!editing) return null + const same = visible(kind), i = same.indexOf(id) + return ( +
+ + + +
+ ) + } return ( -
- {err &&
{err}
} -
- Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}> - {h && } - - - {h && } - - l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} /> - +
+
+ {err && {err}} + {editing && Reorder with ▲▼, hide modules or single rows with ✕. Layout is saved as you go.} + + {editing && } +
-
- - {s.temps.length === 0 &&
No temperature sensors reported by this host.
} - {collapseTemps(s.temps).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 ( -
- {t.label}{t.chip} - - {t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''} -
- ) - })} -
+ {visible('tile').length > 0 && ( +
+ {visible('tile').map((id) =>
{tools(id, 'tile')}{body(id, ctx)}
)} +
+ )} + {visible('card').length > 0 && ( +
+ {visible('card').map((id) =>
{tools(id, 'card')}{body(id, ctx)}
)} +
+ )} - - {s.fans.length === 0 &&
No fan sensors reported by this host.
} - {s.fans.map((f) => ( -
- {f.label}{f.chip} - - {f.rpm} rpm -
- ))} -
- - - {s.net.map((x) => ( -
-
- - {x.iface} - - ↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)} -
- {h?.net[x.iface] && ( - - )} -
- rx · {fmtBytes(x.rx_total)} total - tx · {fmtBytes(x.tx_total)} total -
-
- ))} -
- - - {s.disks.map((d) => ( -
-
- {d.name} - {d.model} - - {fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy -
-
read {fmtRate(d.read_bps)}write {fmtRate(d.write_bps)}
- {h?.disk[d.name] && ( - - )} - -
- ))} -
- - - {s.fs.map((f) => ( -
- {f.mount}{f.type} - {f.size ? : unreachable} - {f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''} -
- ))} -
- - - {!s.ups &&
No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).
} - {s.ups && ( - <> -
- - {s.ups.state} · {s.ups.source} - - {s.ups.model} · {s.ups.driver} -
-
Battery{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min
-
Load{s.ups.load_w} W · {pct(s.ups.load_pct)}
-
Voltagein {s.ups.in_v} V · out {s.ups.out_v} V
- {s.ups.last_event &&
Last event{s.ups.last_event}
} - - )} -
-
+ {editing && hiddenMods.length > 0 && ( +
+

Hidden modules

+
{hiddenMods.map((id) => )}
+
+ )}
) } diff --git a/web/src/api.ts b/web/src/api.ts index 16ea605..1d1edb0 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -38,7 +38,13 @@ export interface Series { temp: Record } +// 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"). +export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[] } + export const api = { + dashPrefs: () => req('GET', '/api/prefs/dashboard'), + saveDashPrefs: (p: DashPrefs) => req('PUT', '/api/prefs/dashboard', p), metrics: () => req('GET', '/api/metrics'), metricsHistory: () => req('GET', '/api/metrics/history'), login: (password: string) => req('POST', '/api/login', { password }), diff --git a/web/src/styles.css b/web/src/styles.css index 8b872e7..e10ba2c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -106,3 +106,18 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .status { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; } .rail .group-head.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); } @media (max-width: 800px) { .dash { padding: 8px; gap: 8px; } .kpis, .cards { gap: 8px; } .cards { grid-template-columns: 1fr; } } + +/* dashboard: customize mode */ +.dash-bar { display: flex; align-items: center; gap: 8px; min-height: 28px; } +.dash-bar .hint { color: var(--muted); font-size: 12px; } +.btn { padding: 5px 10px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-size: 12px; color: var(--fg); } +.btn.on { background: var(--accent); color: #111; border-color: var(--accent); } +.mod-tools { display: flex; gap: 4px; margin: -4px -4px 6px auto; justify-content: flex-end; } +.mod-tools button { padding: 2px 7px; background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; font-size: 12px; color: var(--muted); } +.mod-tools button:hover:not(:disabled) { color: var(--fg); } .mod-tools button:disabled { opacity: .35; cursor: default; } +.editing .tile, .editing .card { border-style: dashed; } +.hide-item { padding: 0 6px; color: var(--muted); font-size: 12px; border-radius: 4px; } .hide-item:hover { color: var(--bad); background: var(--bg3); } +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { padding: 3px 9px; background: var(--bg3); border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); } +.chip:hover { color: var(--fg); border-color: var(--accent2); } +.chips.inline { margin-top: 6px; }