diff --git a/PLAN.md b/PLAN.md index 128a77a..b94ddbb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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` maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where 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, Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin 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 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..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. General polish and ease of use. The **Deprecate copyparty** item names the heavier pieces (multi-select and bulk actions, folder download, resumable diff --git a/internal/api/server.go b/internal/api/server.go index b33bc56..e543511 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -102,18 +102,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me s.sensors.Add(newStatusSensors(s)) s.alerts = newAlertEngine(s) // The saved dashboard layout carries the sampling interval; apply it on boot. - var raw json.RawMessage var histSec int - st.View(func(x *store.State) { - raw, histSec = x.Dashboard, x.HistorySec - if len(raw) == 0 { - for _, u := range x.Users { - raw = u.Dashboard - break - } - } - }) - s.applyPrefs(raw) + st.View(func(x *store.State) { histSec = x.HistorySec }) if histSec > 0 { 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("GET /api/prefs/history", s.auth(s.getHistoryWindow)) 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("/", s.static) s.mux = m @@ -930,15 +918,11 @@ func (s *Server) migrateUsers() { st.Sessions[tok] = ses } } - if len(st.UI) > 0 || len(st.Dashboard) > 0 { - u := st.UserState(first) - if len(u.UI) == 0 { + if len(st.UI) > 0 { + if u := st.UserState(first); len(u.UI) == 0 { u.UI = st.UI } - if len(u.Dashboard) == 0 { - u.Dashboard = st.Dashboard - } - st.UI, st.Dashboard = nil, nil + st.UI = 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 ---- func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/store/store.go b/internal/store/store.go index 846c9b5..ec227d1 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -41,17 +41,15 @@ func (s *Session) UnmarshalJSON(b []byte) error { // UserState is what follows an account rather than the machine: how the console looks // to that person and how their dashboard is laid out. 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 } type State struct { Terminals []Terminal `json:"terminals"` Sessions map[string]Session `json:"sessions"` // token -> session Users map[string]*UserState `json:"users,omitempty"` - // Dashboard and UI are the pre-users machine-wide prefs; on first boot with users they - // move to the first account. Kept so an old state file still reads. - Dashboard json.RawMessage `json:"dashboard,omitempty"` + // UI is the pre-users machine-wide look; on first boot with users it moves to the first + // account. Kept so an old state file still reads. UI json.RawMessage `json:"ui,omitempty"` 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 diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx deleted file mode 100644 index 7be485f..0000000 --- a/web/src/Dashboard.tsx +++ /dev/null @@ -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; hiddenItems: Set; 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 = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []) - const p = { order: list(raw?.order), hidden: list(raw?.hidden), hiddenItems: list(raw?.hiddenItems), widgets: list(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() - 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
-} -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} - {!x.up && down} - - {x.up && ↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}} - -
- {x.up && h?.net[x.iface] && ( - - )} - {x.up &&
- 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 [loaded, setLoaded] = useState(false) - const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('') - const savedTimer = useRef(undefined) - const hist = useRef(null) - const saveTimer = useRef(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(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(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(null) - const live = useSensorStream>(shapeOf(layout.widgets)) - - if (!latest) return
{err ?
{err}
: }
- - 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 ( -
- - - -
- ) - } - - return ( -
-
- {err && {err}} - {editing ? last {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings - : last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}} - - {saving === 'saving' && saving} - {saving === 'saved' && saved ✓} - {editing && } - -
- - {visible('tile').length > 0 && ( -
- {visible('tile').map((id) =>
{tools(id, 'tile')}{body(id, ctx)}
)} -
- )} - {(layout.widgets.length > 0 || editing) && ( -
- {layout.widgets.map((w, i) => ( -
- {editing && ( -
- - - - -
- )} - -
- ))} - {editing && } - {live.error && !live.data && layout.widgets.length > 0 &&
The widgets' stream isn't answering: {live.error}.
} -
- )} - {editingWidget && setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />} - {picking && setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />} - {visible('card').length > 0 && ( -
- {visible('card').map((id) =>
{tools(id, 'card')}{body(id, ctx)}
)} -
- )} - - {editing && hiddenMods.length > 0 && ( -
-

Hidden modules

-
{hiddenMods.map((id) => )}
-
- )} -
- ) -} diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx index 35cc163..c068cef 100644 --- a/web/src/Disks.tsx +++ b/web/src/Disks.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' -import { fmtBytes, fmtRate } from './Dashboard' +import { fmtBytes, fmtRate } from './fmt' import TaskOutput from './TaskOutput' import { Btn, Loading, Spinner, askText, toast } from './ui' import { useRefreshMs } from './prefs' diff --git a/web/src/Files.tsx b/web/src/Files.tsx index 9bd40d2..b7bacf4 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -1,6 +1,6 @@ import { DragEvent, useCallback, useEffect, useRef, useState } from 'react' 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 { useHashSel } from './hashsel' diff --git a/web/src/Gallery.tsx b/web/src/Gallery.tsx index a6f5a32..cb818a4 100644 --- a/web/src/Gallery.tsx +++ b/web/src/Gallery.tsx @@ -57,6 +57,13 @@ export default function Gallery() { return (
+
+ These widgets are for dashboards outside Ham Lab — Glance, Home Assistant, a wall + display. Each one is a page at /embed/widget you put in an + iframe, reading this machine through a read-only sensor token (Settings → Access + tokens). Ham Lab shows you these numbers on its own pages; this tab is for handing them + to something else. +
{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" /> diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index f0366f5..bdaaccc 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -4,7 +4,6 @@ import Rail, { Group } from './Rail' import TerminalPane, { TermHandle } from './TerminalPane' import TermTabs from './TermTabs' import KeyBar from './KeyBar' -import Dashboard from './Dashboard' import Gallery from './Gallery' import Docker from './Docker' import Alerts from './Alerts' @@ -51,7 +50,6 @@ const BellIcon = ( ) const GROUPS: Group[] = [ - { id: 'dashboard', label: 'Dashboard', icon: '◔' }, { id: 'procs', label: 'Processes', icon: '▚' }, { id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'services', label: 'Services', icon: '◈' }, @@ -68,8 +66,8 @@ const GROUPS: Group[] = [ { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'dashboard' | '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'] +type View = 'terminal' | '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'] // URL hash is the source of truth for where you are: #//. Back and @@ -102,7 +100,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: const listRate = useRefreshMs(LIST_FLOOR) const [terms, setTerms] = useState([]) const [active, setActive] = useState(null) - const [view, setView] = useState(() => parseHash()?.view ?? 'dashboard') + const [view, setView] = useState(() => parseHash()?.view ?? 'procs') const [drawer, setDrawer] = useState(false) const [online, setOnline] = useState(true) 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 // inside the machine change read the page we were leaving — so a link from Settings to a // 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]) // 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' && } {view === 'users' && } {view === 'space' && } - {view === 'machines' && { switchMachine(n); setView('dashboard') }} />} - {view === 'dashboard' && } + {view === 'machines' && { switchMachine(n); setView('procs') }} />} {view === 'widgets' && } {view === 'docker' && } {view === 'alerts' && 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: {machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {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 : ''} + {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 : ''}
) diff --git a/web/src/Space.tsx b/web/src/Space.tsx index b3919a6..1eaea1f 100644 --- a/web/src/Space.tsx +++ b/web/src/Space.tsx @@ -4,7 +4,7 @@ // 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 { api, SpaceFile, SpaceTarget, SpaceTree } from './api' -import { fmtBytes } from './Dashboard' +import { fmtBytes } from './fmt' import { useHashSel } from './hashsel' import { useRefreshMs } from './prefs' import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui' diff --git a/web/src/api.ts b/web/src/api.ts index 31b35c7..7147b54 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -90,7 +90,6 @@ export interface Series { // 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 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 ServiceDetail extends ServiceUnit { props: Record } @@ -208,8 +207,6 @@ export const api = { serviceAction: (unit: string, action: string) => req('POST', `/api/services/${encodeURIComponent(unit)}/${action}`), historyWindow: () => req('GET', '/api/prefs/history', undefined, true), setHistoryWindow: (seconds: number) => req('PUT', '/api/prefs/history', { seconds }, true), - dashPrefs: () => req('GET', '/api/prefs/dashboard'), - saveDashPrefs: (p: DashPrefs) => req('PUT', '/api/prefs/dashboard', p), login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true), users: () => req('GET', '/api/users', undefined, true), addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true), diff --git a/web/src/fmt.ts b/web/src/fmt.ts new file mode 100644 index 0000000..e7b0bc1 --- /dev/null +++ b/web/src/fmt.ts @@ -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]}` +} diff --git a/web/src/sampleAdapter.ts b/web/src/sampleAdapter.ts index b26dd75..47ab5fd 100644 --- a/web/src/sampleAdapter.ts +++ b/web/src/sampleAdapter.ts @@ -1,7 +1,7 @@ // 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 values (the live stream) and sensor metadata (the catalog: labels, thresholds). -import { Sample, Series } from './api' +import { Sample } from './api' import { Sensor } from './sensors' export type Values = Record @@ -19,7 +19,6 @@ export function mergeValues(parts: unknown[]): Values { return out } // 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 str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '') @@ -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 // 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 -}