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:
co-authored by
Claude Fable 5.1
parent
a4f39859b6
commit
bbf0f2580f
+5
-1
@@ -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<Me | null | undefined>(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) }
|
||||
}, [])
|
||||
|
||||
|
||||
+3
-1
@@ -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))
|
||||
|
||||
+13
-11
@@ -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<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 }
|
||||
}
|
||||
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<number | undefined>(undefined)
|
||||
const hist = useRef<Series | null>(null)
|
||||
const saveTimer = useRef<number | undefined>(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 <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
||||
|
||||
const edit: Edit = {
|
||||
@@ -371,14 +379,8 @@ export default function Dashboard() {
|
||||
<div className={'dash' + (editing ? ' editing' : '')}>
|
||||
<div className="dash-bar">
|
||||
{err && <span className="error">{err}</span>}
|
||||
{editing ? (
|
||||
<label className="hint">Update every
|
||||
<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>}
|
||||
{editing ? <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
|
||||
: <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {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>}
|
||||
|
||||
+4
-2
@@ -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<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 [err, setErr] = useState('')
|
||||
const [task, setTask] = useState<string | null>(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 <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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+2
-8
@@ -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<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 = '▁▂▃▄▅▆▇█'
|
||||
|
||||
// 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 [paused, setPaused] = useState(false)
|
||||
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 () => {
|
||||
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() {
|
||||
<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' + (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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+18
-1
@@ -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<string, string> }) {
|
||||
|
||||
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 }) {
|
||||
))}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
+5
-3
@@ -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<Me | null>(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<Term[]>([])
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
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.
|
||||
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)
|
||||
|
||||
+2
-2
@@ -168,8 +168,8 @@ export const api = {
|
||||
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||||
tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true),
|
||||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||
uiPrefs: () => req<{ theme?: string }>('GET', '/api/prefs/ui', undefined, true),
|
||||
saveUIPrefs: (p: { theme?: string }) => req<void>('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<void>('PUT', '/api/prefs/ui', p, true),
|
||||
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
|
||||
terminals: () => req<Term[]>('GET', '/api/terminals'),
|
||||
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user