diff --git a/web/src/App.tsx b/web/src/App.tsx index 911f5f4..57935fe 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { api, Me, Unauthenticated } from './api' import Login from './Login' import Shell from './Shell' import { applyTheme, currentThemeId } from './themes' +import { getRefreshMs, setRefreshMs } from './prefs' export default function App() { const [me, setMe] = useState(undefined) @@ -10,7 +11,10 @@ export default function App() { const refresh = useCallback(async () => { try { const m = await api.meHere(); setMe(m) - api.uiPrefs().then((p) => { if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) }).catch(() => { /* keep local */ }) + api.uiPrefs().then((p) => { + if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) + if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs) + }).catch(() => { /* keep local */ }) } catch (e) { if (e instanceof Unauthenticated) setMe(null) } }, []) diff --git a/web/src/Cron.tsx b/web/src/Cron.tsx index 23ffb99..c56a522 100644 --- a/web/src/Cron.tsx +++ b/web/src/Cron.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' import { Logs } from './Services' import { Btn, Loading, Spinner, toast } from './ui' +import { LIST_FLOOR, useRefreshMs } from './prefs' const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } function rel(ms: number): string { @@ -180,8 +181,9 @@ export default function Cron() { const load = useCallback(async () => { try { setOv(await api.cron()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) + const rate = useRefreshMs(LIST_FLOOR) useEffect(() => { load() }, [load]) - useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 30000); return () => clearInterval(t) }, [load]) + useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, rate); return () => clearInterval(t) }, [load, rate]) const needle = q.trim().toLowerCase() const match = (...s: string[]) => !needle || s.some((x) => x.toLowerCase().includes(needle)) diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index a4c8d22..6673410 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -2,6 +2,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { api, DashPrefs, Sample, Series, Temp } from './api' import Sparkline from './Sparkline' import { Loading, Spinner, toast } from './ui' +import { fmtMs, useRefreshMs } from './prefs' const MAX = 300 const S1 = 'var(--s1)', S2 = 'var(--s2)' @@ -23,8 +24,8 @@ const MODULES: Module[] = [ ] const byId = (id: string) => MODULES.find((m) => m.id === id) -const INTERVALS = [100, 250, 500, 1000, 2000] as const const DEFAULT_INTERVAL = 2000 +const clampSampler = (ms: number) => Math.min(2000, Math.max(100, ms)) interface Layout { order: string[]; hidden: Set; hiddenItems: Set; intervalMs: number } @@ -37,7 +38,6 @@ function normalize(p: DashPrefs): Layout { return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv } } const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs }) -const fmtMs = (ms: number) => (ms >= 1000 ? `${ms / 1000} s` : `${ms} ms`) const fmtWindow = (ms: number) => { const s = Math.round((MAX * ms) / 1000); return s >= 60 ? `${Math.round(s / 60)} min` : `${s} s` } // ---- formatting ---- @@ -266,8 +266,9 @@ export default function Dashboard() { const savedTimer = useRef(undefined) const hist = useRef(null) const saveTimer = useRef(undefined) + const refreshMs = useRefreshMs() const intervalRef = useRef(DEFAULT_INTERVAL) - intervalRef.current = layout.intervalMs + intervalRef.current = refreshMs // Persist layout changes server-side (debounced) so every device sees the same dashboard. const update = useCallback((fn: (l: Layout) => Layout) => { @@ -328,6 +329,13 @@ export default function Dashboard() { return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) } }, []) + // 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 + if (!latest) return
{err ?
{err}
: }
const edit: Edit = { @@ -371,14 +379,8 @@ export default function Dashboard() {
{err && {err}} - {editing ? ( - - ) : last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}} + {editing ? last {fmtWindow(latest.interval_ms || layout.intervalMs)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings + : last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}} {saving === 'saving' && saving} {saving === 'saved' && saved ✓} diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx index 2b426d8..74680ea 100644 --- a/web/src/Disks.tsx +++ b/web/src/Disks.tsx @@ -3,6 +3,7 @@ import { api, Sample, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './a import { fmtBytes, fmtRate } from './Dashboard' import TaskOutput from './TaskOutput' import { Btn, Loading, Spinner, toast } from './ui' +import { useRefreshMs } from './prefs' const pct = (v: number) => `${Math.round(v)}%` const KIND: Record = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', other: 'disk' } @@ -178,6 +179,7 @@ export default function Disks() { const [live, setLive] = useState(null) const [err, setErr] = useState('') const [task, setTask] = useState(null) + const rate = useRefreshMs() const load = useCallback(async () => { try { @@ -190,9 +192,9 @@ export default function Disks() { let stop = false const tick = async () => { if (!stop && document.visibilityState === 'visible') { try { setLive(await api.metrics()) } catch { /* ignore */ } } } tick() - const t = setInterval(tick, 2000) + const t = setInterval(tick, rate) return () => { stop = true; clearInterval(t) } - }, []) + }, [rate]) if (!devs) return
{err ?
{err}
: }
const liveFor = (n: string) => live?.disks.find((d) => d.name === n) diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index 14f74f9..8d8923b 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -1,6 +1,7 @@ import { FormEvent, useCallback, useEffect, useState } from 'react' import { api, MachineStatus, Me } from './api' import { Btn, Loading, toast } from './ui' +import { LIST_FLOOR, useRefreshMs } from './prefs' const fmtUptime = (s: number) => { 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 ago = (iso?: string) => { if (!iso) return 'never'; const s = (Date.now() - Date.parse(iso)) / 1000; return s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` } @@ -21,7 +22,8 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange const load = useCallback(async () => { try { const r = await api.machines(); setList(r.nodes); setFp(r.fingerprint); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setList([]) } }, []) - useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t) }, [load]) + const rate = useRefreshMs(LIST_FLOOR) + useEffect(() => { load(); const t = setInterval(load, rate); return () => clearInterval(t) }, [load, rate]) async function add(e: FormEvent) { e.preventDefault() diff --git a/web/src/Procs.tsx b/web/src/Procs.tsx index 30fae14..0a33a84 100644 --- a/web/src/Procs.tsx +++ b/web/src/Procs.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { api, Proc, ProcTable } from './api' import { fmtBytes, fmtRate } from './Dashboard' import { Btn, Loading, toast } from './ui' +import { useRefreshMs } from './prefs' type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time' const pct = (v: number) => `${Math.round(v)}%` @@ -10,7 +11,6 @@ const fmtDur = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Mat const fmtStart = (ms: number) => { const d = new Date(ms), now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) } const STATE: Record = { R: 'running', S: 'sleeping', D: 'disk wait', Z: 'zombie', T: 'stopped', t: 'traced', I: 'idle' } -const INTERVALS = [500, 1000, 2000, 5000] as const const BLOCKS = '▁▂▃▄▅▆▇█' // btop-style gradient bar: the track carries the green→yellow→red gradient and a cover @@ -47,7 +47,7 @@ export default function Procs() { const [sel, setSel] = useState(null) const [paused, setPaused] = useState(false) const [showTop, setShowTop] = useState(() => localStorage.getItem('hamlab.procs.top') !== '0') - const [interval, setIntervalMs] = useState(() => Number(localStorage.getItem('hamlab.procs.interval')) || 2000) + const interval = useRefreshMs() const load = useCallback(async () => { try { setT(await api.procs()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } @@ -55,7 +55,6 @@ export default function Procs() { useEffect(() => { load() const id = window.setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, interval) - localStorage.setItem('hamlab.procs.interval', String(interval)) return () => window.clearInterval(id) }, [load, paused, interval]) @@ -129,11 +128,6 @@ export default function Procs() { setQ(e.target.value)} /> - {rows.length} shown
diff --git a/web/src/Services.tsx b/web/src/Services.tsx index ab00ac0..3b164ab 100644 --- a/web/src/Services.tsx +++ b/web/src/Services.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api' import { Btn, Loading, Spinner, toast } from './ui' +import { LIST_FLOOR, useRefreshMs } from './prefs' type Filter = 'all' | 'running' | 'failed' | 'enabled' @@ -110,8 +111,9 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo const load = useCallback(async () => { try { setD(await api.service(name)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name]) + const rate = useRefreshMs(LIST_FLOOR) useEffect(() => { setD(null); setFile(''); setTab('logs'); load() }, [load]) - useEffect(() => { const t = setInterval(load, 5000); return () => clearInterval(t) }, [load]) + useEffect(() => { const t = setInterval(load, rate); return () => clearInterval(t) }, [load, rate]) useEffect(() => { if (tab === 'file' && !file) api.serviceFile(name).then((r) => setFile(r.content)).catch((e) => setFile(String(e))) }, [tab, file, name]) async function act(action: string) { @@ -181,8 +183,9 @@ export default function Services() { try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setLoaded(true) } }, []) + const rate = useRefreshMs(LIST_FLOOR) useEffect(() => { load() }, [load]) - useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 10000); return () => clearInterval(t) }, [load]) + useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, rate); return () => clearInterval(t) }, [load, rate]) const needle = q.trim().toLowerCase() const shown = units.filter((u) => { diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index cb12848..a819945 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from 'react' import { api, Me } from './api' import { applyTheme, currentThemeId, THEMES } from './themes' import { Btn, Loading, toast } from './ui' +import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs, LIST_FLOOR } from './prefs' const fmtUptime = (s: number) => { 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` } @@ -29,6 +30,7 @@ function Preview({ vars }: { vars: Record }) { export default function Settings({ me }: { me: Me }) { const [theme, setTheme] = useState(currentThemeId()) + const [refresh, setRefresh] = useState(getRefreshMs()) const [tokens, setTokens] = useState<{ name: string; created: string }[] | null>(null) const [cur, setCur] = useState('') const [pw1, setPw1] = useState('') @@ -40,7 +42,11 @@ export default function Settings({ me }: { me: Me }) { const pick = async (id: string) => { applyTheme(id); setTheme(id) - try { await api.saveUIPrefs({ theme: id }) } catch { /* local still applied */ } + try { await api.saveUIPrefs({ theme: id, refreshMs: getRefreshMs() }) } catch { /* local still applied */ } + } + const pickRefresh = async (ms: number) => { + setRefreshMs(ms); setRefresh(ms) + try { await api.saveUIPrefs({ theme: currentThemeId(), refreshMs: ms }) } catch { /* local still applied */ } } async function changePassword(e: FormEvent) { @@ -69,6 +75,17 @@ export default function Settings({ me }: { me: Me }) { ))}
+

Refresh rate

every live view, on every machine
+
+
+ {REFRESH_OPTIONS.map((ms) => )} +
+
+ Dashboard charts, the Processes tab, disk activity and the machine list all poll at this rate. The dashboard's sampler on each machine follows it too (fastest 100 ms, slowest 2 s; charts keep 300 points, so the window is 300 × rate). + Lists that shell out on the host — services, cron, updates — never go faster than {fmtMs(LIST_FLOOR)}. +
+
+

Account · {me.host}

diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 79a404c..b82d739 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -14,6 +14,7 @@ import Machines from './Machines' import Settings from './Settings' import MachineSwitcher from './MachineSwitcher' import { MachineStatus, setMachine } from './api' +import { LIST_FLOOR, useRefreshMs } from './prefs' import Power from './Power' import { Btn, Toasts, toast } from './ui' @@ -52,6 +53,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: const [remoteMe, setRemoteMe] = useState(null) const [remoteErr, setRemoteErr] = useState('') const me: Me = machine ? (remoteMe ?? { ...hubMe, host: machine, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe + const listRate = useRefreshMs(LIST_FLOOR) const [terms, setTerms] = useState([]) const [active, setActive] = useState(null) const [view, setView] = useState('dashboard') @@ -72,7 +74,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: // Machine list for the switcher (hub-local), refreshed while the console is open. const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, []) - useEffect(() => { loadMachines(); const t = setInterval(loadMachines, 15000); return () => clearInterval(t) }, [loadMachines]) + useEffect(() => { loadMachines(); const t = setInterval(loadMachines, Math.max(listRate, 5000)); return () => clearInterval(t) }, [loadMachines, listRate]) // Switching machines: reset everything that belongs to a host, then re-fetch its identity. const switchMachine = (name: string | null) => { @@ -86,9 +88,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: let stop = false const tick = async () => { try { const m = await api.me(); if (!stop) { setRemoteMe(m); setRemoteErr('') } } catch (e) { if (!stop) setRemoteErr(e instanceof Error ? e.message : 'unreachable') } } tick() - const t = setInterval(tick, 30000) + const t = setInterval(tick, Math.max(listRate, 5000)) return () => { stop = true; clearInterval(t) } - }, [machine]) + }, [machine, listRate]) async function create() { setCreating(true) diff --git a/web/src/api.ts b/web/src/api.ts index 4e47640..307bcf6 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -168,8 +168,8 @@ export const api = { changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, true), tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true), revokeToken: (name: string) => req('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), - uiPrefs: () => req<{ theme?: string }>('GET', '/api/prefs/ui', undefined, true), - saveUIPrefs: (p: { theme?: string }) => req('PUT', '/api/prefs/ui', p, true), + uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true), + saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req('PUT', '/api/prefs/ui', p, true), removeMachine: (name: string) => req('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true), terminals: () => req('GET', '/api/terminals'), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), diff --git a/web/src/prefs.ts b/web/src/prefs.ts new file mode 100644 index 0000000..cf155ed --- /dev/null +++ b/web/src/prefs.ts @@ -0,0 +1,32 @@ +// Global refresh rate: one setting for every live view. Kept in localStorage for instant +// use and mirrored to the hub's UI prefs so it follows the login. +import { useEffect, useState } from 'react' + +export const REFRESH_OPTIONS = [250, 500, 1000, 2000, 5000] as const +export const DEFAULT_REFRESH = 2000 +// Lists that shell out on the host (services, cron, updates, machines) never poll faster than this. +export const LIST_FLOOR = 2000 +const KEY = 'hamlab.refresh' + +export function getRefreshMs(): number { + const v = Number(localStorage.getItem(KEY)) + return v >= 100 && v <= 60000 ? v : DEFAULT_REFRESH +} + +export function setRefreshMs(ms: number) { + localStorage.setItem(KEY, String(ms)) + window.dispatchEvent(new CustomEvent('hamlab:refresh', { detail: ms })) +} + +export function fmtMs(ms: number): string { return ms >= 1000 ? `${ms / 1000} s` : `${ms} ms` } + +// useRefreshMs re-renders the caller when the global rate changes. +export function useRefreshMs(floor = 0): number { + const [ms, setMs] = useState(getRefreshMs()) + useEffect(() => { + const h = (e: Event) => setMs((e as CustomEvent).detail) + window.addEventListener('hamlab:refresh', h) + return () => window.removeEventListener('hamlab:refresh', h) + }, []) + return Math.max(ms, floor) +}