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:
co-authored by
Claude Fable 5.1
parent
7053cce7fe
commit
eb70daf129
@@ -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
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+32
-2
@@ -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) {
|
||||
|
||||
@@ -18,6 +18,7 @@ type Terminal struct {
|
||||
type State struct {
|
||||
Terminals []Terminal `json:"terminals"`
|
||||
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 {
|
||||
|
||||
+281
-130
@@ -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<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 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 <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>
|
||||
}
|
||||
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<string, Temp[]>()
|
||||
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 <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() {
|
||||
const [latest, setLatest] = useState<Sample | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [layout, setLayout] = useState<Layout>(() => normalize({}))
|
||||
const [editing, setEditing] = useState(false)
|
||||
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(() => {
|
||||
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 <div className="dash"><div className="center muted">{err || '…'}</div></div>
|
||||
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 (
|
||||
<div className="dash">
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="kpis">
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="cards">
|
||||
<Card title="Temperatures">
|
||||
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
|
||||
{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 (
|
||||
<div className="row" key={`${t.chip}/${t.label}`}>
|
||||
<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)}` : ''}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
|
||||
<Card title="Fans">
|
||||
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
|
||||
{s.fans.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</span>
|
||||
<div className={'dash' + (editing ? ' editing' : '')}>
|
||||
<div className="dash-bar">
|
||||
{err && <span className="error">{err}</span>}
|
||||
{editing && <span className="hint">Reorder with ▲▼, hide modules or single rows with ✕. Layout is saved as you go.</span>}
|
||||
<span className="spacer" />
|
||||
{editing && <button className="btn" onClick={reset}>Reset</button>}
|
||||
<button className={'btn' + (editing ? ' on' : '')} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
|
||||
</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>
|
||||
{visible('tile').length > 0 && (
|
||||
<div className="kpis">
|
||||
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
|
||||
</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>
|
||||
{visible('card').length > 0 && (
|
||||
<div className="cards">
|
||||
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
|
||||
</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>
|
||||
{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>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,13 @@ export interface Series {
|
||||
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 = {
|
||||
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
|
||||
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
|
||||
metrics: () => req<Sample>('GET', '/api/metrics'),
|
||||
metricsHistory: () => req<Series>('GET', '/api/metrics/history'),
|
||||
login: (password: string) => req<void>('POST', '/api/login', { password }),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user