Sensors: read-only tokens, live stream, Prometheus, status sensors, widgets, embeds

- sensors:read tokens open the sensor endpoints (here and on managed machines
  through the hub) and nothing else; full tokens are never accepted from a URL
- /api/sensors/stream: a shaped query that keeps answering, over SSE
- /metrics: Prometheus text format
- services, updates, ZFS and SMART as background status sensors
- widget library over the sensor API; custom widgets on the dashboard
- /embed/widget: one live widget in its own bundle, the only frameable path
- Dashboard tab is now Monitors

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 07:37:10 -07:00
co-authored by Claude Fable 5.1
parent 468f45251d
commit b05a053920
20 changed files with 950 additions and 56 deletions
+19 -2
View File
@@ -31,7 +31,8 @@ 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 [tokens, setTokens] = useState<{ name: string; created: string; scope?: string }[] | null>(null)
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
const [cur, setCur] = useState('')
const [pw1, setPw1] = useState('')
const [pw2, setPw2] = useState('')
@@ -97,16 +98,32 @@ export default function Settings({ me }: { me: Me }) {
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
<section className="card">
<div className="muted small" style={{ marginBottom: 8 }}>Tokens let another Ham Lab (a hub) manage this host. They're issued when a hub adds this machine and revoked when it removes it.</div>
<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 }}>{t.scope ? 'read-only' : 'full'}</span>
<span className="muted small">issued {new Date(t.created).toLocaleString()}</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={async () => {
const name = window.prompt('Name for the read-only token (what will use it)', 'glance')?.trim()
if (!name) return
try { setFresh(await api.createReadToken(name)); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
}}>+ Read-only sensor token</Btn>
</div>
</section>
<div className="dash-bar"><h2 className="section">About</h2></div>