The process table's columns fit the box they are in, the rate control saves, curves are timed on the viewer's clock and keep to the stride, the power icon is drawn, and a young history draws in from the right instead of being stretched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
475 lines
25 KiB
TypeScript
475 lines
25 KiB
TypeScript
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
|
import Sparkline from './Sparkline'
|
|
import { Loading, Spinner, askYes, toast } from './ui'
|
|
import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
|
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
|
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
|
|
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
|
|
import WidgetPicker from './WidgetPicker'
|
|
|
|
const MAX = 300
|
|
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
|
|
|
// ---- module registry: the customizable blocks, in default order ----
|
|
type Kind = 'tile' | 'card'
|
|
interface Module { id: string; label: string; kind: Kind }
|
|
const MODULES: Module[] = [
|
|
{ id: 'cpu', label: 'CPU', kind: 'tile' },
|
|
{ id: 'mem', label: 'Memory', kind: 'tile' },
|
|
{ id: 'load', label: 'Load', kind: 'tile' },
|
|
{ id: 'uptime', label: 'Uptime', kind: 'tile' },
|
|
{ id: 'temps', label: 'Temperatures', kind: 'card' },
|
|
{ id: 'fans', label: 'Fans', kind: 'card' },
|
|
{ id: 'net', label: 'Network', kind: 'card' },
|
|
{ id: 'disks', label: 'Disks', kind: 'card' },
|
|
{ id: 'fs', label: 'Filesystems', kind: 'card' },
|
|
{ id: 'ups', label: 'UPS', kind: 'card' },
|
|
]
|
|
const byId = (id: string) => MODULES.find((m) => m.id === id)
|
|
|
|
const DEFAULT_INTERVAL = 2000
|
|
const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
|
|
|
|
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
|
|
|
|
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
|
|
function normalize(raw: DashPrefs): Layout {
|
|
// Saved prefs are whatever some client last wrote; a wrong shape must not take the page down.
|
|
const list = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
|
|
const p = { order: list<string>(raw?.order), hidden: list<string>(raw?.hidden), hiddenItems: list<string>(raw?.hiddenItems), widgets: list<WidgetSpec>(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined }
|
|
const known = new Set(MODULES.map((m) => m.id))
|
|
const order = (p.order ?? []).filter((id) => known.has(id))
|
|
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
|
|
const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL
|
|
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv,
|
|
widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') }
|
|
}
|
|
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets })
|
|
|
|
// ---- formatting ----
|
|
// A live value either starts a new point or is averaged into the one being built — the same
|
|
// rule the machine itself uses to keep its history, so the tail of a six-hour graph isn't a
|
|
// minute of 2-second samples crushed against the right edge. n is how many values this point
|
|
// has had, including this one.
|
|
function pushPoint(arr: number[], v: number, fresh: boolean, n: number) {
|
|
if (!fresh && arr.length) { arr[arr.length - 1] += (v - arr[arr.length - 1]) / n; return }
|
|
arr.push(v)
|
|
if (arr.length > MAX) arr.shift()
|
|
}
|
|
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
|
|
|
|
export function fmtBytes(b: number): string {
|
|
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
|
let i = 0
|
|
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
|
|
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
|
|
}
|
|
export function fmtRate(bps: number): string {
|
|
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
|
|
let i = 0
|
|
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
|
|
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
|
|
}
|
|
function fmtUptime(s: number): string {
|
|
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
|
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
}
|
|
const pct = (v: number) => `${Math.round(v)}%`
|
|
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' { return frac >= bad ? 'bad' : frac >= warn ? 'warn' : '' }
|
|
|
|
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest.
|
|
function collapseTemps(temps: Temp[]): (Temp & { key: string })[] {
|
|
const byChip = new Map<string, Temp[]>()
|
|
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t)
|
|
const out: (Temp & { key: string })[] = []
|
|
for (const [chip, list] of byChip) {
|
|
if (list.length <= 8) { out.push(...list.map((t) => ({ ...t, key: `${t.chip}/${t.label}` }))); continue }
|
|
const hot = list.reduce((a, b) => (b.c > a.c ? b : a))
|
|
out.push({ ...hot, label: `${list.length} sensors · hottest ${hot.label}`, chip, key: `${chip}/*` })
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- small building blocks ----
|
|
function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
|
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
|
}
|
|
function Legend({ items }: { items: { name: string; color: string }[] }) {
|
|
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
|
|
}
|
|
|
|
interface Edit {
|
|
on: boolean
|
|
hideItem: (key: string) => void
|
|
unhideItem: (key: string) => void
|
|
hiddenItems: Set<string>
|
|
}
|
|
// In customize mode every row gets a ✕; hidden rows come back as chips under the module.
|
|
function HideBtn({ k, edit }: { k: string; edit: Edit }) {
|
|
return edit.on ? <button className="hide-item" title="hide" onClick={() => edit.hideItem(k)}>✕</button> : null
|
|
}
|
|
function HiddenChips({ prefix, edit, label }: { prefix: string; edit: Edit; label: (k: string) => string }) {
|
|
if (!edit.on) return null
|
|
const keys = [...edit.hiddenItems].filter((k) => k.startsWith(prefix + ':'))
|
|
if (keys.length === 0) return null
|
|
return <div className="chips inline">{keys.map((k) => <button key={k} className="chip" title="show again" onClick={() => edit.unhideItem(k)}>+ {label(k.slice(prefix.length + 1))}</button>)}</div>
|
|
}
|
|
|
|
// ---- module bodies ----
|
|
interface Ctx { s: Sample; h: Series | null; edit: Edit }
|
|
|
|
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
|
|
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
|
|
}
|
|
|
|
function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
|
const shown = (key: string) => !edit.hiddenItems.has(key)
|
|
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
|
switch (id) {
|
|
case 'cpu':
|
|
return (
|
|
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length === 0 ? undefined : s.cores.length <= 16 ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}>
|
|
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
|
|
</Tile>
|
|
)
|
|
case 'mem':
|
|
return (
|
|
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
|
|
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
|
|
</Tile>
|
|
)
|
|
case 'load':
|
|
return <Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
|
|
case 'uptime':
|
|
return <Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
|
|
|
|
case 'temps': {
|
|
const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`))
|
|
return (
|
|
<>
|
|
<h2>Temperatures</h2>
|
|
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
|
|
{rows.map((t) => {
|
|
const limit = t.crit || t.max || 100
|
|
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
|
|
return (
|
|
<div className="row" key={t.key}>
|
|
<span className="k">{t.label}<small>{t.chip}</small></span>
|
|
<Meter frac={t.c / limit} cls={cls} />
|
|
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}<HideBtn k={`temp:${t.key}`} edit={edit} /></span>
|
|
</div>
|
|
)
|
|
})}
|
|
<HiddenChips prefix="temp" edit={edit} label={(k) => k.replace('/*', '')} />
|
|
</>
|
|
)
|
|
}
|
|
case 'fans': {
|
|
const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`))
|
|
return (
|
|
<>
|
|
<h2>Fans</h2>
|
|
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
|
|
{rows.map((f) => (
|
|
<div className="row" key={`${f.chip}/${f.label}`}>
|
|
<span className="k">{f.label}<small>{f.chip}</small></span>
|
|
<span />
|
|
<span className="num">{f.rpm} rpm<HideBtn k={`fan:${f.chip}/${f.label}`} edit={edit} /></span>
|
|
</div>
|
|
))}
|
|
<HiddenChips prefix="fan" edit={edit} label={(k) => k} />
|
|
</>
|
|
)
|
|
}
|
|
case 'net':
|
|
return (
|
|
<>
|
|
<h2>Network</h2>
|
|
{s.net.filter((x) => shown(`net:${x.iface}`)).map((x) => (
|
|
<div className="sub-card" key={x.iface}>
|
|
<div className="sub-head">
|
|
<span className={'dot ' + (x.up ? 'ok' : '')} />
|
|
<span>{x.iface}</span>
|
|
{!x.up && <span className="muted small">down</span>}
|
|
<span className="spacer" />
|
|
{x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
|
|
<HideBtn k={`net:${x.iface}`} edit={edit} />
|
|
</div>
|
|
{x.up && h?.net[x.iface] && (
|
|
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
|
|
)}
|
|
{x.up && <div className="legend">
|
|
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
|
|
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
|
|
</div>}
|
|
</div>
|
|
))}
|
|
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
|
|
</>
|
|
)
|
|
case 'disks':
|
|
return (
|
|
<>
|
|
<h2>Disks</h2>
|
|
{s.disks.length === 0 && <div className="empty">No block devices found.</div>}
|
|
{s.disks.filter((d) => shown(`disk:${d.name}`)).map((d) => (
|
|
<div className="sub-card" key={d.name}>
|
|
<div className="sub-head">
|
|
<span>{d.name}</span>
|
|
<span className="model">{d.model}</span>
|
|
<span className="spacer" />
|
|
<span className="num">{fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
|
|
<HideBtn k={`disk:${d.name}`} edit={edit} />
|
|
</div>
|
|
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
|
|
{h?.disk[d.name] && (
|
|
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
|
|
)}
|
|
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
|
|
</div>
|
|
))}
|
|
<HiddenChips prefix="disk" edit={edit} label={(k) => k} />
|
|
</>
|
|
)
|
|
case 'fs':
|
|
return (
|
|
<>
|
|
<h2>Filesystems</h2>
|
|
{s.fs.filter((f) => shown(`fs:${f.mount}`)).map((f) => (
|
|
<div className="row" key={f.mount}>
|
|
<span className="k">{f.mount}<small>{f.type}</small></span>
|
|
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
|
|
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}<HideBtn k={`fs:${f.mount}`} edit={edit} /></span>
|
|
</div>
|
|
))}
|
|
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
|
</>
|
|
)
|
|
case 'ups':
|
|
return (
|
|
<>
|
|
<h2>UPS</h2>
|
|
{!s.ups && <div className="empty">No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).</div>}
|
|
{s.ups && (
|
|
<>
|
|
<div className="sub-head">
|
|
<span className={'dot ' + (s.ups.on_battery ? 'bad' : 'ok')} />
|
|
<span>{s.ups.state} · {s.ups.source}</span>
|
|
<span className="spacer" />
|
|
<span className="model">{s.ups.model} · {s.ups.driver}</span>
|
|
</div>
|
|
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
|
|
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
|
|
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
|
|
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ---- the dashboard ----
|
|
export default function Dashboard() {
|
|
const [latest, setLatest] = useState<Sample | null>(null)
|
|
const [err, setErr] = useState('')
|
|
const [layout, setLayout] = useState<Layout>(() => normalize({}))
|
|
const [editing, setEditing] = useState(false)
|
|
const [loaded, setLoaded] = useState(false)
|
|
const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('')
|
|
const savedTimer = useRef<number | undefined>(undefined)
|
|
const hist = useRef<Series | null>(null)
|
|
const saveTimer = useRef<number | undefined>(undefined)
|
|
const refreshMs = useRefreshMs()
|
|
const historySec = useHistorySec()
|
|
|
|
// Persist layout changes server-side (debounced) so every device sees the same dashboard.
|
|
const update = useCallback((fn: (l: Layout) => Layout) => {
|
|
setLayout((prev) => {
|
|
const next = fn(prev)
|
|
clearTimeout(saveTimer.current)
|
|
setSaving('saving')
|
|
saveTimer.current = window.setTimeout(async () => {
|
|
try {
|
|
await api.saveDashPrefs(toPrefs(next))
|
|
setSaving('saved')
|
|
clearTimeout(savedTimer.current)
|
|
savedTimer.current = window.setTimeout(() => setSaving(''), 1500)
|
|
} catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
|
}, 300)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let stop = false
|
|
api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout'))
|
|
return () => { stop = true }
|
|
}, [])
|
|
|
|
// Everything on this page comes from the sensor API: the catalog for names, labels and
|
|
// thresholds; one batch of histories to seed the curves; one stream for the values.
|
|
const catalog = useRef<Catalog>(new Map())
|
|
const refetching = useRef(false)
|
|
const binN = useRef(1) // values averaged into the point currently being built
|
|
const lastPush = useRef(0) // when that point was started, by this browser's clock
|
|
const stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
|
|
const seed = useCallback(async () => {
|
|
try {
|
|
catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
|
|
hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current)
|
|
lastPush.current = Date.now(); binN.current = 1
|
|
setErr('')
|
|
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
}, [])
|
|
useEffect(() => { seed() }, [seed, stock.resumed]) // on load, and again after the page was hidden: the curves have a gap to refill
|
|
useEffect(() => {
|
|
if (!stock.data || catalog.current.size === 0) return
|
|
const v: Values = mergeValues(stock.data)
|
|
if (stale(v, catalog.current) && !refetching.current) { // something appeared (a disk, an interface): learn its labels
|
|
refetching.current = true
|
|
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
|
|
}
|
|
const s = sampleOf(v, catalog.current)
|
|
const h = hist.current
|
|
if (h) {
|
|
// The stride is timed on this clock alone (lastPush), never against the history's own
|
|
// timestamps: those are the machine's, and a viewer whose clock runs minutes slow would
|
|
// otherwise wait those minutes before its curves gained a point.
|
|
const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs)
|
|
const fresh = np.fresh
|
|
lastPush.current = np.start
|
|
binN.current = fresh ? 1 : binN.current + 1
|
|
if (fresh) pushPoint(h.t, s.t, true, 1)
|
|
const n = h.t.length
|
|
// a curve that has no point in this one yet starts one, whatever the others are doing:
|
|
// an interface or disk that appeared mid-point must still line up with the time axis
|
|
const push = (arr: number[], x: number) => pushPoint(arr, x, fresh || arr.length < n, binN.current)
|
|
push(h.cpu, s.cpu); push(h.mem, s.mem.used)
|
|
for (const x of s.net) {
|
|
const c = (h.net[x.iface] ??= { rx: [], tx: [] })
|
|
pad(c.rx, n - 1); pad(c.tx, n - 1); push(c.rx, x.rx_bps); push(c.tx, x.tx_bps)
|
|
}
|
|
for (const d of s.disks) {
|
|
const c = (h.disk[d.name] ??= { r: [], w: [], u: [] })
|
|
pad(c.r, n - 1); pad(c.w, n - 1); pad(c.u, n - 1); push(c.r, d.read_bps); push(c.w, d.write_bps); push(c.u, d.util_pct)
|
|
}
|
|
for (const t of s.temps) {
|
|
const c = (h.temp[`${t.chip}/${t.label}`] ??= [])
|
|
pad(c, n - 1); push(c, t.c)
|
|
}
|
|
}
|
|
setLatest(s)
|
|
}, [stock.data, historySec, refreshMs])
|
|
useEffect(() => { if (stock.error && !latest) setErr(stock.error) }, [stock.error, latest])
|
|
|
|
// The sampler on this machine follows the global rate (clamped to what it supports).
|
|
useEffect(() => {
|
|
if (!loaded) return
|
|
const want = clampSampler(refreshMs)
|
|
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
|
|
}, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
|
|
const [picking, setPicking] = useState(false)
|
|
const [editingWidget, setEditingWidget] = useState<WidgetSpec | null>(null)
|
|
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
|
|
|
|
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
|
|
|
const edit: Edit = {
|
|
on: editing,
|
|
hiddenItems: layout.hiddenItems,
|
|
hideItem: (k) => update((l) => ({ ...l, hiddenItems: new Set([...l.hiddenItems, k]) })),
|
|
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
|
|
}
|
|
const ctx: Ctx = { s: latest, h: hist.current, edit }
|
|
// Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
|
|
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
|
|
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
|
|
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
|
|
|
|
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
|
|
const move = (id: string, dir: -1 | 1) => update((l) => {
|
|
const kind = byId(id)!.kind
|
|
const same = l.order.filter((x) => byId(x)?.kind === kind && !l.hidden.has(x))
|
|
const i = same.indexOf(id), j = i + dir
|
|
if (j < 0 || j >= same.length) return l
|
|
const order = [...l.order]
|
|
const a = order.indexOf(id), b = order.indexOf(same[j])
|
|
;[order[a], order[b]] = [order[b], order[a]]
|
|
return { ...l, order }
|
|
})
|
|
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
|
|
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
|
|
const reset = async () => { if (await askYes(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
|
|
|
|
const tools = (id: string, kind: Kind) => {
|
|
if (!editing) return null
|
|
const same = visible(kind), i = same.indexOf(id)
|
|
return (
|
|
<div className="mod-tools">
|
|
<button title="move up" disabled={i === 0} onClick={() => move(id, -1)}>▲</button>
|
|
<button title="move down" disabled={i === same.length - 1} onClick={() => move(id, 1)}>▼</button>
|
|
<button title="hide module" onClick={() => hideMod(id)}>hide</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={'dash' + (editing ? ' editing' : '')}>
|
|
<div className="dash-bar">
|
|
{err && <span className="error">{err}</span>}
|
|
{editing ? <span className="hint">last {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
|
|
: <span className="hint">last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
|
|
<span className="spacer" />
|
|
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
|
|
{saving === 'saved' && <span className="saved">saved ✓</span>}
|
|
{editing && <button className="btn" onClick={reset}>Reset</button>}
|
|
<button className={'btn' + (editing ? ' on' : '')} disabled={!loaded} title={loaded ? '' : 'layout not loaded'} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
|
|
</div>
|
|
|
|
{visible('tile').length > 0 && (
|
|
<div className="kpis">
|
|
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
|
|
</div>
|
|
)}
|
|
{(layout.widgets.length > 0 || editing) && (
|
|
<div className="kpis widgets">
|
|
{layout.widgets.map((w, i) => (
|
|
<div className={'tile w-' + w.type} key={w.id}>
|
|
{editing && (
|
|
<div className="mod-tools">
|
|
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
|
|
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
|
|
<button title="change its sensor, type or title" onClick={() => setEditingWidget(w)}>edit</button>
|
|
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
|
|
</div>
|
|
)}
|
|
<Widget spec={w} data={live.data ? live.data[w.id] : undefined} />
|
|
</div>
|
|
))}
|
|
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
|
|
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering: {live.error}.</div>}
|
|
</div>
|
|
)}
|
|
{editingWidget && <WidgetPicker initial={editingWidget} onClose={() => setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />}
|
|
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
|
{visible('card').length > 0 && (
|
|
<div className="cards">
|
|
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
|
|
</div>
|
|
)}
|
|
|
|
{editing && hiddenMods.length > 0 && (
|
|
<div className="card">
|
|
<h2>Hidden modules</h2>
|
|
<div className="chips">{hiddenMods.map((id) => <button key={id} className="chip" onClick={() => showMod(id)}>+ {byId(id)!.label}</button>)}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|