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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 13:46:41 -07:00
co-authored by Claude Fable 5.1
parent 7053cce7fe
commit eb70daf129
7 changed files with 351 additions and 144 deletions
+5 -1
View File
@@ -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 `/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 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 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. 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 / - **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 `wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a
+1 -1
View File
@@ -141,7 +141,7 @@ func serve(cfg *config.Config) error {
col := metrics.NewCollector() col := metrics.NewCollector()
go col.Run(context.Background()) 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{ hs := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: srv.Handler(), Handler: srv.Handler(),
+32 -2
View File
@@ -19,6 +19,7 @@ import (
"hamlab/internal/auth" "hamlab/internal/auth"
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/store"
"hamlab/internal/term" "hamlab/internal/term"
) )
@@ -30,6 +31,7 @@ type Server struct {
limiter auth.Limiter limiter auth.Limiter
mgr *term.Manager mgr *term.Manager
metrics *metrics.Collector metrics *metrics.Collector
store *store.Store
version string version string
started time.Time started time.Time
user string user string
@@ -38,9 +40,9 @@ type Server struct {
mux *http.ServeMux 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() 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")} started: time.Now(), host: host, user: os.Getenv("USER")}
if u, err := user.Current(); err == nil && u.Username != "" { if u, err := user.Current(); err == nil && u.Username != "" {
s.user = 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/terminals/{id}/attach", s.auth(s.attach))
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest)) m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory)) 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("/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
@@ -185,6 +189,32 @@ func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, s.metrics.History()) 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 ---- // ---- terminals ----
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
+2 -1
View File
@@ -17,7 +17,8 @@ type Terminal struct {
type State struct { type State struct {
Terminals []Terminal `json:"terminals"` 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 { type Store struct {
+290 -139
View File
@@ -1,10 +1,39 @@
import { ReactNode, useEffect, useRef, useState } from 'react' import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, Sample, Series, Temp } from './api' import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline' import Sparkline from './Sparkline'
const MAX = 300 const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)' 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<string>; hiddenItems: Set<string> }
// 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 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) } 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` return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
} }
const pct = (v: number) => `${Math.round(v)}%` 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 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 <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
return (
<div className="tile">
<div className="label">{label}</div>
<div className="value">{value}</div>
{sub && <div className="sub">{sub}</div>}
{children}
</div>
)
}
function Card({ title, children }: { title: string; children: ReactNode }) {
return <section className="card"><h2>{title}</h2>{children}</section>
}
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>
}
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest. // 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<string, Temp[]>() const byChip = new Map<string, Temp[]>()
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t) 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) { 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)) 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 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>
<span className="spacer" />
<span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>
<HideBtn k={`net:${x.iface}`} edit={edit} />
</div>
{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} />
)}
<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() { export default function Dashboard() {
const [latest, setLatest] = useState<Sample | null>(null) const [latest, setLatest] = useState<Sample | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [layout, setLayout] = useState<Layout>(() => normalize({}))
const [editing, setEditing] = useState(false)
const hist = useRef<Series | null>(null) const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(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(() => { useEffect(() => {
let stop = false let stop = false
let timer: number | undefined let timer: number | undefined
api.dashPrefs().then((p) => { if (!stop) setLayout(normalize(p)) }).catch(() => { /* defaults */ })
const seed = async () => { const seed = async () => {
try { hist.current = await api.metricsHistory(); setLatest(await api.metrics()); setErr('') } try { hist.current = await api.metricsHistory(); setLatest(await api.metrics()); setErr('') }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
@@ -112,117 +309,71 @@ export default function Dashboard() {
}, []) }, [])
if (!latest) return <div className="dash"><div className="center muted">{err || '…'}</div></div> if (!latest) return <div className="dash"><div className="center muted">{err || '…'}</div></div>
const h = hist.current
const s = latest const edit: Edit = {
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0 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 (
<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 ( return (
<div className="dash"> <div className={'dash' + (editing ? ' editing' : '')}>
{err && <div className="error">{err}</div>} <div className="dash-bar">
<div className="kpis"> {err && <span className="error">{err}</span>}
<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))}`}> {editing && <span className="hint">Reorder with ▲▼, hide modules or single rows with ✕. Layout is saved as you go.</span>}
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />} <span className="spacer" />
</Tile> {editing && <button className="btn" onClick={reset}>Reset</button>}
<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)}` : ''}`}> <button className={'btn' + (editing ? ' on' : '')} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
</Tile>
<Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
<Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
</div> </div>
<div className="cards"> {visible('tile').length > 0 && (
<Card title="Temperatures"> <div className="kpis">
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>} {visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
{collapseTemps(s.temps).map((t) => { </div>
const limit = t.crit || t.max || 100 )}
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : '' {visible('card').length > 0 && (
return ( <div className="cards">
<div className="row" key={`${t.chip}/${t.label}`}> {visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
<span className="k">{t.label}<small>{t.chip}</small></span> </div>
<Meter frac={t.c / limit} cls={cls} /> )}
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}</span>
</div>
)
})}
</Card>
<Card title="Fans"> {editing && hiddenMods.length > 0 && (
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>} <div className="card">
{s.fans.map((f) => ( <h2>Hidden modules</h2>
<div className="row" key={`${f.chip}/${f.label}`}> <div className="chips">{hiddenMods.map((id) => <button key={id} className="chip" onClick={() => showMod(id)}>+ {byId(id)!.label}</button>)}</div>
<span className="k">{f.label}<small>{f.chip}</small></span> </div>
<span /> )}
<span className="num">{f.rpm} rpm</span>
</div>
))}
</Card>
<Card title="Network">
{s.net.map((x) => (
<div className="sub-card" key={x.iface}>
<div className="sub-head">
<span className={'dot ' + (x.up ? 'ok' : '')} />
<span>{x.iface}</span>
<span className="spacer" />
<span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>
</div>
{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} />
)}
<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>
))}
</Card>
<Card title="Disks">
{s.disks.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>
</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>
))}
</Card>
<Card title="Filesystems">
{s.fs.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)}` : ''}</span>
</div>
))}
</Card>
<Card title="UPS">
{!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>}
</>
)}
</Card>
</div>
</div> </div>
) )
} }
+6
View File
@@ -38,7 +38,13 @@ export interface Series {
temp: Record<string, number[]> temp: Record<string, number[]>
} }
// 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 = { export const api = {
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
metrics: () => req<Sample>('GET', '/api/metrics'), metrics: () => req<Sample>('GET', '/api/metrics'),
metricsHistory: () => req<Series>('GET', '/api/metrics/history'), metricsHistory: () => req<Series>('GET', '/api/metrics/history'),
login: (password: string) => req<void>('POST', '/api/login', { password }), login: (password: string) => req<void>('POST', '/api/login', { password }),
+15
View File
@@ -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; } .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); } .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; } } @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; }