Global refresh rate in Settings drives every live view and each machine's sampler; per-page pickers removed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 13:27:13 -07:00
co-authored by Claude Fable 5.1
parent a4f39859b6
commit bbf0f2580f
11 changed files with 92 additions and 32 deletions
+5 -1
View File
@@ -3,6 +3,7 @@ import { api, Me, Unauthenticated } from './api'
import Login from './Login' import Login from './Login'
import Shell from './Shell' import Shell from './Shell'
import { applyTheme, currentThemeId } from './themes' import { applyTheme, currentThemeId } from './themes'
import { getRefreshMs, setRefreshMs } from './prefs'
export default function App() { export default function App() {
const [me, setMe] = useState<Me | null | undefined>(undefined) const [me, setMe] = useState<Me | null | undefined>(undefined)
@@ -10,7 +11,10 @@ export default function App() {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const m = await api.meHere(); setMe(m) 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) } } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
}, []) }, [])
+3 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services' import { Logs } from './Services'
import { Btn, Loading, Spinner, toast } from './ui' 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' }) } 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 { function rel(ms: number): string {
@@ -180,8 +181,9 @@ export default function Cron() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { setOv(await api.cron()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } try { setOv(await api.cron()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, []) }, [])
const rate = useRefreshMs(LIST_FLOOR)
useEffect(() => { load() }, [load]) 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 needle = q.trim().toLowerCase()
const match = (...s: string[]) => !needle || s.some((x) => x.toLowerCase().includes(needle)) const match = (...s: string[]) => !needle || s.some((x) => x.toLowerCase().includes(needle))
+13 -11
View File
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api' import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline' import Sparkline from './Sparkline'
import { Loading, Spinner, toast } from './ui' import { Loading, Spinner, toast } from './ui'
import { fmtMs, useRefreshMs } from './prefs'
const MAX = 300 const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)' 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 byId = (id: string) => MODULES.find((m) => m.id === id)
const INTERVALS = [100, 250, 500, 1000, 2000] as const
const DEFAULT_INTERVAL = 2000 const DEFAULT_INTERVAL = 2000
const clampSampler = (ms: number) => Math.min(2000, Math.max(100, ms))
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number } interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; 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 } 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 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` } const fmtWindow = (ms: number) => { const s = Math.round((MAX * ms) / 1000); return s >= 60 ? `${Math.round(s / 60)} min` : `${s} s` }
// ---- formatting ---- // ---- formatting ----
@@ -266,8 +266,9 @@ export default function Dashboard() {
const savedTimer = useRef<number | undefined>(undefined) const savedTimer = useRef<number | undefined>(undefined)
const hist = useRef<Series | null>(null) const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(undefined) const saveTimer = useRef<number | undefined>(undefined)
const refreshMs = useRefreshMs()
const intervalRef = useRef(DEFAULT_INTERVAL) 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. // Persist layout changes server-side (debounced) so every device sees the same dashboard.
const update = useCallback((fn: (l: Layout) => Layout) => { const update = useCallback((fn: (l: Layout) => Layout) => {
@@ -328,6 +329,13 @@ export default function Dashboard() {
return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) } 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 <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div> if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
const edit: Edit = { const edit: Edit = {
@@ -371,14 +379,8 @@ export default function Dashboard() {
<div className={'dash' + (editing ? ' editing' : '')}> <div className={'dash' + (editing ? ' editing' : '')}>
<div className="dash-bar"> <div className="dash-bar">
{err && <span className="error">{err}</span>} {err && <span className="error">{err}</span>}
{editing ? ( {editing ? <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
<label className="hint">Update every : <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<select className="btn" value={layout.intervalMs} onChange={(e) => update((l) => ({ ...l, intervalMs: Number(e.target.value) }))}>
{INTERVALS.map((ms) => <option key={ms} value={ms}>{fmtMs(ms)}</option>)}
</select>
· last {fmtWindow(layout.intervalMs)} · ▲▼ reorder · ✕ hide
</label>
) : <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<span className="spacer" /> <span className="spacer" />
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>} {saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
{saving === 'saved' && <span className="saved">saved ✓</span>} {saving === 'saved' && <span className="saved">saved ✓</span>}
+4 -2
View File
@@ -3,6 +3,7 @@ import { api, Sample, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './a
import { fmtBytes, fmtRate } from './Dashboard' import { fmtBytes, fmtRate } from './Dashboard'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import { Btn, Loading, Spinner, toast } from './ui' import { Btn, Loading, Spinner, toast } from './ui'
import { useRefreshMs } from './prefs'
const pct = (v: number) => `${Math.round(v)}%` const pct = (v: number) => `${Math.round(v)}%`
const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', other: 'disk' } const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', other: 'disk' }
@@ -178,6 +179,7 @@ export default function Disks() {
const [live, setLive] = useState<Sample | null>(null) const [live, setLive] = useState<Sample | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [task, setTask] = useState<string | null>(null) const [task, setTask] = useState<string | null>(null)
const rate = useRefreshMs()
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -190,9 +192,9 @@ export default function Disks() {
let stop = false let stop = false
const tick = async () => { if (!stop && document.visibilityState === 'visible') { try { setLive(await api.metrics()) } catch { /* ignore */ } } } const tick = async () => { if (!stop && document.visibilityState === 'visible') { try { setLive(await api.metrics()) } catch { /* ignore */ } } }
tick() tick()
const t = setInterval(tick, 2000) const t = setInterval(tick, rate)
return () => { stop = true; clearInterval(t) } return () => { stop = true; clearInterval(t) }
}, []) }, [rate])
if (!devs) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Scanning disks…" />}</div> if (!devs) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Scanning disks…" />}</div>
const liveFor = (n: string) => live?.disks.find((d) => d.name === n) const liveFor = (n: string) => live?.disks.find((d) => d.name === n)
+3 -1
View File
@@ -1,6 +1,7 @@
import { FormEvent, useCallback, useEffect, useState } from 'react' import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, MachineStatus, Me } from './api' import { api, MachineStatus, Me } from './api'
import { Btn, Loading, toast } from './ui' 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 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` } 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 () => { 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([]) } 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) { async function add(e: FormEvent) {
e.preventDefault() e.preventDefault()
+2 -8
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import { api, Proc, ProcTable } from './api' import { api, Proc, ProcTable } from './api'
import { fmtBytes, fmtRate } from './Dashboard' import { fmtBytes, fmtRate } from './Dashboard'
import { Btn, Loading, toast } from './ui' import { Btn, Loading, toast } from './ui'
import { useRefreshMs } from './prefs'
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time' type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
const pct = (v: number) => `${Math.round(v)}%` 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 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<string, string> = { R: 'running', S: 'sleeping', D: 'disk wait', Z: 'zombie', T: 'stopped', t: 'traced', I: 'idle' } const STATE: Record<string, string> = { 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 = '▁▂▃▄▅▆▇█' const BLOCKS = '▁▂▃▄▅▆▇█'
// btop-style gradient bar: the track carries the green→yellow→red gradient and a cover // 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<number | null>(null) const [sel, setSel] = useState<number | null>(null)
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
const [showTop, setShowTop] = useState<boolean>(() => localStorage.getItem('hamlab.procs.top') !== '0') const [showTop, setShowTop] = useState<boolean>(() => localStorage.getItem('hamlab.procs.top') !== '0')
const [interval, setIntervalMs] = useState<number>(() => Number(localStorage.getItem('hamlab.procs.interval')) || 2000) const interval = useRefreshMs()
const load = useCallback(async () => { const load = useCallback(async () => {
try { setT(await api.procs()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } try { setT(await api.procs()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
@@ -55,7 +55,6 @@ export default function Procs() {
useEffect(() => { useEffect(() => {
load() load()
const id = window.setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, interval) const id = window.setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, interval)
localStorage.setItem('hamlab.procs.interval', String(interval))
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [load, paused, interval]) }, [load, paused, interval])
@@ -129,11 +128,6 @@ export default function Procs() {
<input placeholder="filter name, command, user, pid" value={q} onChange={(e) => setQ(e.target.value)} /> <input placeholder="filter name, command, user, pid" value={q} onChange={(e) => setQ(e.target.value)} />
<button className={'chip' + (mine ? ' on' : '')} onClick={() => setMine(!mine)}>hide root</button> <button className={'chip' + (mine ? ' on' : '')} onClick={() => setMine(!mine)}>hide root</button>
<button className={'chip' + (paused ? ' on' : '')} onClick={() => setPaused(!paused)}>{paused ? 'paused' : 'live'}</button> <button className={'chip' + (paused ? ' on' : '')} onClick={() => setPaused(!paused)}>{paused ? 'paused' : 'live'}</button>
<label className="muted small">refresh
<select className="btn" value={interval} onChange={(e) => setIntervalMs(Number(e.target.value))}>
{INTERVALS.map((ms) => <option key={ms} value={ms}>{ms >= 1000 ? `${ms / 1000} s` : `${ms} ms`}</option>)}
</select>
</label>
<span className="muted small">{rows.length} shown</span> <span className="muted small">{rows.length} shown</span>
</div> </div>
+5 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api' import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
import { Btn, Loading, Spinner, toast } from './ui' import { Btn, Loading, Spinner, toast } from './ui'
import { LIST_FLOOR, useRefreshMs } from './prefs'
type Filter = 'all' | 'running' | 'failed' | 'enabled' type Filter = 'all' | 'running' | 'failed' | 'enabled'
@@ -110,8 +111,9 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
const load = useCallback(async () => { const load = useCallback(async () => {
try { setD(await api.service(name)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } try { setD(await api.service(name)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [name]) }, [name])
const rate = useRefreshMs(LIST_FLOOR)
useEffect(() => { setD(null); setFile(''); setTab('logs'); load() }, [load]) 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]) 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) { 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') } try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
finally { setLoaded(true) } finally { setLoaded(true) }
}, []) }, [])
const rate = useRefreshMs(LIST_FLOOR)
useEffect(() => { load() }, [load]) 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 needle = q.trim().toLowerCase()
const shown = units.filter((u) => { const shown = units.filter((u) => {
+18 -1
View File
@@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, Me } from './api' import { api, Me } from './api'
import { applyTheme, currentThemeId, THEMES } from './themes' import { applyTheme, currentThemeId, THEMES } from './themes'
import { Btn, Loading, toast } from './ui' 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` } 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<string, string> }) {
export default function Settings({ me }: { me: Me }) { export default function Settings({ me }: { me: Me }) {
const [theme, setTheme] = useState(currentThemeId()) const [theme, setTheme] = useState(currentThemeId())
const [refresh, setRefresh] = useState(getRefreshMs())
const [tokens, setTokens] = useState<{ name: string; created: string }[] | null>(null) const [tokens, setTokens] = useState<{ name: string; created: string }[] | null>(null)
const [cur, setCur] = useState('') const [cur, setCur] = useState('')
const [pw1, setPw1] = useState('') const [pw1, setPw1] = useState('')
@@ -40,7 +42,11 @@ export default function Settings({ me }: { me: Me }) {
const pick = async (id: string) => { const pick = async (id: string) => {
applyTheme(id); setTheme(id) 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) { async function changePassword(e: FormEvent) {
@@ -69,6 +75,17 @@ export default function Settings({ me }: { me: Me }) {
))} ))}
</div> </div>
<div className="dash-bar"><h2 className="section">Refresh rate</h2><span className="spacer" /><span className="muted small">every live view, on every machine</span></div>
<section className="card">
<div className="chips">
{REFRESH_OPTIONS.map((ms) => <button key={ms} className={'chip' + (refresh === ms ? ' on' : '')} onClick={() => pickRefresh(ms)}>{fmtMs(ms)}</button>)}
</div>
<div className="muted small" style={{ marginTop: 8 }}>
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)}.
</div>
</section>
<div className="dash-bar"><h2 className="section">Account · {me.host}</h2></div> <div className="dash-bar"><h2 className="section">Account · {me.host}</h2></div>
<form className="card addform" onSubmit={changePassword}> <form className="card addform" onSubmit={changePassword}>
<label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label> <label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label>
+5 -3
View File
@@ -14,6 +14,7 @@ import Machines from './Machines'
import Settings from './Settings' import Settings from './Settings'
import MachineSwitcher from './MachineSwitcher' import MachineSwitcher from './MachineSwitcher'
import { MachineStatus, setMachine } from './api' import { MachineStatus, setMachine } from './api'
import { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power' import Power from './Power'
import { Btn, Toasts, toast } from './ui' import { Btn, Toasts, toast } from './ui'
@@ -52,6 +53,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
const [remoteMe, setRemoteMe] = useState<Me | null>(null) const [remoteMe, setRemoteMe] = useState<Me | null>(null)
const [remoteErr, setRemoteErr] = useState('') const [remoteErr, setRemoteErr] = useState('')
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: machine, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe 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<Term[]>([]) const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [view, setView] = useState<View>('dashboard') const [view, setView] = useState<View>('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. // 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 */ } }, []) 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. // Switching machines: reset everything that belongs to a host, then re-fetch its identity.
const switchMachine = (name: string | null) => { const switchMachine = (name: string | null) => {
@@ -86,9 +88,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
let stop = false 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') } } 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() tick()
const t = setInterval(tick, 30000) const t = setInterval(tick, Math.max(listRate, 5000))
return () => { stop = true; clearInterval(t) } return () => { stop = true; clearInterval(t) }
}, [machine]) }, [machine, listRate])
async function create() { async function create() {
setCreating(true) setCreating(true)
+2 -2
View File
@@ -168,8 +168,8 @@ export const api = {
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true), changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true), tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true),
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
uiPrefs: () => req<{ theme?: string }>('GET', '/api/prefs/ui', undefined, true), uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true),
saveUIPrefs: (p: { theme?: string }) => req<void>('PUT', '/api/prefs/ui', p, true), saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true), removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
terminals: () => req<Term[]>('GET', '/api/terminals'), terminals: () => req<Term[]>('GET', '/api/terminals'),
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
+32
View File
@@ -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<number>).detail)
window.addEventListener('hamlab:refresh', h)
return () => window.removeEventListener('hamlab:refresh', h)
}, [])
return Math.max(ms, floor)
}