Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
18 KiB
TypeScript
309 lines
18 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { fetchCatalog } from './sensors'
|
|
import { api, Me, TokenInfo, TranscodeSetting, UpdateStatus } from './api'
|
|
import { savePrefs } from './accents'
|
|
import { applyTheme, currentThemeId, THEMES } from './themes'
|
|
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
|
import { fmtMs, fmtWindowSec, getHistorySec, getRefreshMs, HISTORY_OPTIONS, HISTORY_POINTS, REFRESH_OPTIONS, setHistorySec, setRefreshMs, strideMs } 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` }
|
|
|
|
// Small painted preview of a theme: rail, header, a tile and a bar, in its own colours.
|
|
function Preview({ vars }: { vars: Record<string, string> }) {
|
|
const v = (k: string) => vars[k]
|
|
return (
|
|
<div className="tprev" style={{ background: v('--bg'), borderColor: v('--border') }}>
|
|
<div className="tprev-head" style={{ background: v('--bg2'), borderColor: v('--border') }}><span style={{ color: v('--accent') }}>Ham Lab</span><span className="tprev-dot" style={{ background: v('--ok') }} /></div>
|
|
<div className="tprev-body">
|
|
<div className="tprev-rail" style={{ background: v('--bg2'), borderColor: v('--border') }}>
|
|
<span style={{ background: v('--bg3'), boxShadow: `inset 2px 0 0 ${v('--accent')}` }} /><span /><span />
|
|
</div>
|
|
<div className="tprev-main">
|
|
<div className="tprev-tile" style={{ background: v('--bg2'), borderColor: v('--border') }}>
|
|
<i style={{ color: v('--muted') }}>CPU</i><b style={{ color: v('--fg') }}>12%</b>
|
|
<div className="tprev-bar" style={{ background: v('--track') }}><div style={{ width: '40%', background: v('--s1') }} /></div>
|
|
</div>
|
|
<div className="tprev-term" style={{ background: v('--term-bg'), color: v('--accent2') }}>$ _</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Settings({ me }: { me: Me }) {
|
|
const [theme, setTheme] = useState(currentThemeId())
|
|
const [refresh, setRefresh] = useState(getRefreshMs())
|
|
const [history, setHistory] = useState(getHistorySec())
|
|
const [histErr, setHistErr] = useState('')
|
|
const [tokens, setTokens] = useState<TokenInfo[] | null>(null)
|
|
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
|
|
const [making, setMaking] = useState(false)
|
|
|
|
const loadTokens = useCallback(async () => { try { setTokens(await api.tokens()) } catch { setTokens([]) } }, [])
|
|
useEffect(() => { loadTokens() }, [loadTokens])
|
|
|
|
const pick = async (id: string) => {
|
|
applyTheme(id); setTheme(id)
|
|
await savePrefs()
|
|
}
|
|
const pickRefresh = async (ms: number) => {
|
|
setRefreshMs(ms); setRefresh(ms)
|
|
await savePrefs()
|
|
}
|
|
// The window is the machine's own setting, not a look: the hub takes it and passes it on.
|
|
useEffect(() => { api.historyWindow().then((h) => { setHistory(h.seconds); setHistorySec(h.seconds) }).catch(() => {}) }, [])
|
|
const pickHistory = async (sec: number) => {
|
|
const was = history
|
|
setHistory(sec); setHistorySec(sec); setHistErr('')
|
|
try { const h = await api.setHistoryWindow(sec); setHistory(h.seconds); setHistorySec(h.seconds) }
|
|
catch (e) { setHistory(was); setHistorySec(was); setHistErr(e instanceof Error ? e.message : 'failed') }
|
|
}
|
|
|
|
async function revoke(name: string) {
|
|
if (!await askYes(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
|
try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
|
}
|
|
|
|
return (
|
|
<div className="dash settings">
|
|
<div className="dash-bar"><h2 className="section">Appearance</h2><span className="spacer" /><span className="muted small">follows you to every device you sign in on</span></div>
|
|
<div className="themes">
|
|
{THEMES.map((t) => (
|
|
<button key={t.id} className={'theme-card' + (theme === t.id ? ' on' : '')} onClick={() => pick(t.id)}>
|
|
<Preview vars={t.vars} />
|
|
<div className="theme-name">{t.name}{theme === t.id && <span className="badge">active</span>}</div>
|
|
<div className="muted small">{t.blurb}</div>
|
|
</button>
|
|
))}
|
|
</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 }}>
|
|
Every page polls at exactly this rate: dashboard, processes, disks, services, cron, updates, machines. The sampler on each machine follows it too, so it is also the finest a graph can be — how far back the graphs reach is the next setting down.
|
|
Fast rates cost CPU on the host, especially on the list pages, which run commands to refresh. Nothing is throttled behind your back.
|
|
</div>
|
|
</section>
|
|
|
|
<div className="dash-bar"><h2 className="section">Graph history</h2><span className="spacer" /><span className="muted small">every graph, on every machine</span></div>
|
|
<section className="card">
|
|
<div className="chips">
|
|
{HISTORY_OPTIONS.map((sec) => <button key={sec} className={'chip' + (history === sec ? ' on' : '')} onClick={() => pickHistory(sec)}>{fmtWindowSec(sec)}</button>)}
|
|
</div>
|
|
{histErr && <div className="error" style={{ marginTop: 8 }}>{histErr}</div>}
|
|
<div className="muted small" style={{ marginTop: 8 }}>
|
|
How far back every sparkline reaches — the dashboard, the processes page, the disks
|
|
page and any sparkline widget. Each machine keeps {HISTORY_POINTS} points per curve whatever you
|
|
pick, so a wider window is a coarser one: <b>{fmtWindowSec(history)}</b> puts a point every{' '}
|
|
{fmtWindowSec(strideMs(history, refresh) / 1000)}, each the average of what happened
|
|
in between rather than one instant out of it. Memory on the machine doesn't change.
|
|
The history is kept in memory, so a machine that restarts starts its graphs again.
|
|
</div>
|
|
</section>
|
|
|
|
<TranscodeDevice />
|
|
|
|
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
|
<section className="card">
|
|
<div className="muted small" style={{ marginBottom: 8 }}>A <b>full</b> token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A <b>read-only</b> token can only read sensors — that's the one to give a dashboard.</div>
|
|
{tokens === null && <Loading label="Loading…" />}
|
|
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
|
|
{tokens && tokens.map((t) => (
|
|
<div className="row" key={t.name}>
|
|
<span className="k mono">{t.name}</span>
|
|
<span className="badge" style={{ marginLeft: 0 }} title={t.sensors?.length ? t.sensors.join(', ') : undefined}>{t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'}</span>
|
|
<span className="muted small">issued {new Date(t.created).toLocaleString()}{t.issued_by ? ` by ${t.issued_by}` : ''}</span>
|
|
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
|
|
</div>
|
|
))}
|
|
{fresh && (
|
|
<div className="notice attn" style={{ marginTop: 8 }}>
|
|
<div>Token for <b>{fresh.name}</b> — shown once, copy it now:</div>
|
|
<div className="mono small" style={{ wordBreak: 'break-all', userSelect: 'all', margin: '6px 0' }}>{fresh.token}</div>
|
|
<Btn className="btn" onClick={async () => { try { await navigator.clipboard.writeText(fresh.token); toast('copied') } catch { toast('select and copy it by hand', 'err') } }}>Copy</Btn>{' '}
|
|
<Btn className="btn" onClick={() => setFresh(null)}>Done</Btn>
|
|
</div>
|
|
)}
|
|
<div className="svc-actions" style={{ marginTop: 8 }}>
|
|
<Btn className="btn" onClick={() => setMaking(true)}>+ Read-only sensor token</Btn>
|
|
</div>
|
|
</section>
|
|
|
|
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
|
|
|
|
<Updates me={me} />
|
|
|
|
<div className="dash-bar"><h2 className="section">About</h2></div>
|
|
<section className="card">
|
|
<div className="props">
|
|
<div><span className="k">Version</span><span className="mono">{me.version}</span></div>
|
|
<div><span className="k">Host</span><span>{me.host} · {me.arch}</span></div>
|
|
<div><span className="k">Runs as</span><span>{me.user}</span></div>
|
|
<div><span className="k">Uptime</span><span>{fmtUptime(me.uptime)}</span></div>
|
|
{me.fingerprint && <div><span className="k">Certificate</span><span className="mono small">{me.fingerprint}</span></div>}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// What is published, and installing it. An update replaces the binary and restarts the
|
|
// daemon, which drops open terminals — so it only ever happens when it is asked for.
|
|
function Updates({ me }: { me: Me }) {
|
|
const [st, setSt] = useState<UpdateStatus | null>(null)
|
|
const [busy, setBusy] = useState('')
|
|
const [err, setErr] = useState('')
|
|
const load = useCallback(async () => { try { setSt(await api.update()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [])
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const check = async () => {
|
|
setBusy('check'); setErr('')
|
|
try { setSt(await api.checkUpdate()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
finally { setBusy('') }
|
|
}
|
|
const install = async (force = false) => {
|
|
if (!st) return
|
|
if (!await askYes(`Install ${st.latest} on ${me.host}? Ham Lab restarts, which closes open terminals on this machine.`, 'Install')) return
|
|
setBusy('install'); setErr('')
|
|
try {
|
|
await api.installUpdate(st.latest, force)
|
|
toast(`${st.latest} installed — reconnecting`)
|
|
setTimeout(() => window.location.reload(), 4000)
|
|
} catch (e) {
|
|
const m = e instanceof Error ? e.message : 'failed'
|
|
setBusy('')
|
|
if (/^busy:/.test(m) && await askYes(m.replace(/^busy: /, '') + '. Install anyway?', 'Install anyway')) { install(true); return }
|
|
setErr(m)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="dash-bar"><h2 className="section">Updates</h2><span className="spacer" />
|
|
<span className="muted small">{st?.auto ? 'checked daily' : st?.enabled ? 'checked when you ask' : 'switched off'}</span>
|
|
</div>
|
|
<section className="card">
|
|
<div className="props">
|
|
<div><span className="k">Installed</span><span className="mono">{st?.current ?? me.version}</span></div>
|
|
<div><span className="k">Published</span><span className="mono">{st?.checking ? <Spinner /> : st?.latest || '—'}</span></div>
|
|
<div><span className="k">Checked</span><span>{st?.checked ? new Date(st.checked).toLocaleString() : 'not yet'}</span></div>
|
|
<div><span className="k">Source</span><span className="mono small" title={st?.url}>{st?.enabled ? sourceName(st.url) : 'off'}</span></div>
|
|
</div>
|
|
{st?.behind && (
|
|
<div className="notice attn" style={{ marginTop: 10 }}>
|
|
<b>{st.latest}</b> is published; this machine runs <b>{st.current}</b>.
|
|
{st.published && <span className="muted"> Released {new Date(st.published).toLocaleDateString()}.</span>}
|
|
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
|
|
</div>
|
|
)}
|
|
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>{st.current.replace(/^v/, '') === st.latest.replace(/^v/, '') ? 'This is the newest published build.' : `This build is newer than the last published release (${st.latest}).`}</div>}
|
|
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
|
|
<div className="svc-actions" style={{ marginTop: 10 }}>
|
|
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
|
|
{st?.behind && <Btn className="btn on" busy={busy === 'install'} disabled={!!busy} onClick={() => install()}>Install {st.latest}</Btn>}
|
|
<span className="muted small">Installing restarts Ham Lab on this machine. Terminals here close; the shells do not survive it.</span>
|
|
</div>
|
|
</section>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// A source shown as something readable: the repo it is, or the host it is on.
|
|
function sourceName(url: string): string {
|
|
try {
|
|
const u = new URL(url)
|
|
const m = u.pathname.match(/\/api\/v1\/repos\/(.+)$/)
|
|
return m ? `${u.host}/${m[1]}` : u.host + u.pathname
|
|
} catch { return url }
|
|
}
|
|
|
|
// One dialog for a read-only token: what will use it, and (optionally) which sensor
|
|
// families it may see — picked from this machine's own catalog, or typed as patterns.
|
|
function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) {
|
|
const [name, setName] = useState('glance')
|
|
const [pats, setPats] = useState('')
|
|
const [fams, setFams] = useState<string[]>([])
|
|
const [err, setErr] = useState('')
|
|
useEscape(onClose)
|
|
useEffect(() => { fetchCatalog({ machine: null }).then((c) => setFams([...new Set(c.map((s) => s.id.split('.')[0]))].sort())).catch(() => {}) }, [])
|
|
const list = pats.split(',').map((x) => x.trim()).filter(Boolean)
|
|
const toggle = (f: string) => { const p = f === 'uptime' ? f : f + '.*'; setPats((list.includes(p) ? list.filter((x) => x !== p) : [...list, p]).join(', ')) }
|
|
const make = async () => { try { onMade(await api.createReadToken(name.trim(), list)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
|
|
return (
|
|
<div className="modal-back" onClick={onClose}>
|
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); make() }}>
|
|
<h3>Read-only sensor token</h3>
|
|
<label className="muted">Name — what will use it
|
|
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} maxLength={64} spellCheck={false} autoCapitalize="off" />
|
|
</label>
|
|
<label className="muted">Limit it to these sensors — empty means every sensor
|
|
<input className="mono" value={pats} onChange={(e) => setPats(e.target.value)} placeholder="cpu.*, temp.*, zfs.tank.*" spellCheck={false} autoCapitalize="off" />
|
|
</label>
|
|
<div className="chips">{fams.map((f) => <button type="button" key={f} className={'chip' + (list.includes(f === 'uptime' ? f : f + '.*') ? ' on' : '')} onClick={() => toggle(f)}>{f}</button>)}</div>
|
|
{err && <div className="error">{err}</div>}
|
|
<div className="svc-actions"><button type="submit" className="btn on" disabled={!name.trim()}>Create</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Where videos played in Files are made: one device on one machine, for every machine's files.
|
|
// Every machine's devices are asked for live; one that can't be used says why.
|
|
function TranscodeDevice() {
|
|
const [t, setT] = useState<TranscodeSetting | null>(null)
|
|
const [err, setErr] = useState('')
|
|
const [busy, setBusy] = useState('')
|
|
useEffect(() => { api.transcode().then(setT).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
|
const set = async (node: string, device: string) => {
|
|
setBusy(node + '|' + device); setErr('')
|
|
try { setT(await api.setTranscode(node, device)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
setBusy('')
|
|
}
|
|
const sel = t?.selected
|
|
return (
|
|
<>
|
|
<div className="dash-bar"><h2 className="section">Transcode device</h2><span className="spacer" /><span className="muted small">videos in Files, on every machine</span></div>
|
|
<section className="card">
|
|
{!t && !err && <div className="muted small"><Spinner /> asking every machine what it can encode on…</div>}
|
|
{t && (
|
|
<div className="tc-list">
|
|
<div className="tc-machine">
|
|
<div className="tc-host">Anywhere</div>
|
|
<div className="chips">
|
|
<button className={'chip' + (!sel ? ' on' : '')} disabled={!!busy} onClick={() => set('', '')}>The machine the file is on, its best device</button>
|
|
</div>
|
|
</div>
|
|
{t.machines.map((m) => (
|
|
<div key={m.node} className="tc-machine">
|
|
<div className="tc-host">{m.host}{m.node === '' && <span className="muted small"> · this hub</span>}</div>
|
|
{m.error && <div className="muted small">{m.error}</div>}
|
|
<div className="chips">
|
|
{(m.devices ?? []).map((d) => {
|
|
const on = !!sel && sel.node === m.node && sel.device === d.id
|
|
return (
|
|
<button key={d.id} className={'chip' + (on ? ' on' : '') + (d.ok ? '' : ' off')} disabled={!d.ok || !!busy} title={d.ok ? d.id : d.reason}
|
|
onClick={() => set(m.node, d.id)}>
|
|
{busy === m.node + '|' + d.id ? <Spinner size={10} /> : null}{d.name}{d.id !== 'cpu' && d.integrated ? ' (integrated)' : ''}{!d.ok && d.reason ? ` — ${d.reason}` : ''}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
|
<div className="muted small" style={{ marginTop: 10 }}>
|
|
Opening a video in Files plays it YouTube's way — Auto picks the quality the connection takes, any quality can be picked by hand,
|
|
or the file as it is — and every quality is made on the fly, from wherever you seek to, on this device. Video previews
|
|
(thumbnails) are made on it too; image previews stay on each machine's CPU. When it is on another
|
|
machine than the file, that machine reads the file through this hub. If it can't be reached, the file's own machine makes it instead.
|
|
</div>
|
|
</section>
|
|
</>
|
|
)
|
|
}
|