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
+97
View File
@@ -0,0 +1,97 @@
// Client for the sensor API (SENSORS.md): what Ham Lab's own widgets are built on, and
// the same thing any outside consumer uses.
import { useEffect, useRef, useState } from 'react'
import { apiPath } from './api'
export interface Sensor {
id: string; name: string; kind: string; unit?: string; value: number | boolean | string; t: number
min?: number; max?: number; warn?: number; crit?: number; labels?: Record<string, string>
}
export type Shape = string | Shape[] | { [k: string]: Shape }
// Where to read from. Inside the console that's the current machine with the session
// cookie; an embedded widget names a machine and carries a read-only token.
export interface Source { machine?: string | null; token?: string }
function url(src: Source | undefined, path: string, params: Record<string, string>): string {
const base = src?.machine !== undefined ? (src.machine ? `/api/nodes/${src.machine}${path.slice(4)}` : path) : apiPath(path)
const q = new URLSearchParams(params)
if (src?.token) q.set('token', src.token)
const s = q.toString()
return s ? `${base}?${s}` : base
}
export async function fetchCatalog(src?: Source): Promise<Sensor[]> {
const r = await fetch(url(src, '/api/sensors', {}), { credentials: 'same-origin' })
if (!r.ok) throw new Error(r.status === 401 ? 'not authorised' : r.statusText)
return (await r.json()).sensors
}
export async function fetchHistory(id: string, src?: Source): Promise<{ t: number[]; v: number[] } | null> {
const r = await fetch(url(src, `/api/sensors/${encodeURIComponent(id)}/history`, {}), { credentials: 'same-origin' })
return r.ok ? r.json() : null
}
// useSensorStream keeps a shaped query answered: the object comes back filled with whole
// sensors and is replaced on every sample. One stream serves any number of widgets.
export function useSensorStream<T = unknown>(shape: Shape | null, src?: Source, everyMs?: number): { data: T | null; error: string } {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState('')
const key = shape === null ? '' : JSON.stringify(shape)
const srcKey = `${src?.machine ?? '·'}|${src?.token ?? ''}|${everyMs ?? ''}`
const srcRef = useRef(src); srcRef.current = src
useEffect(() => {
if (!key) { setData(null); return }
const params: Record<string, string> = { q: key, full: '1' }
if (everyMs) params.every = String(everyMs)
const es = new EventSource(url(srcRef.current, '/api/sensors/stream', params))
es.onmessage = (e) => { try { setData(JSON.parse(e.data)); setError('') } catch { /* skip a bad frame */ } }
es.onerror = () => setError('reconnecting…') // EventSource retries by itself
return () => es.close()
}, [key, srcKey]) // eslint-disable-line react-hooks/exhaustive-deps
return { data, error }
}
// ---- formatting, by unit ----
const BYTES = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
function scaled(v: number): string {
let i = 0
while (v >= 1024 && i < BYTES.length - 1) { v /= 1024; i++ }
return `${v >= 100 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${BYTES[i]}`
}
function duration(s: number): string {
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 > 0 ? `${m}m` : `${Math.round(s)}s`
}
export function fmtValue(v: Sensor['value'] | null | undefined, unit?: string): string {
if (v === null || v === undefined) return '—'
if (typeof v === 'boolean') return v ? 'yes' : 'no'
if (typeof v === 'string') return v
switch (unit) {
case 'B': return scaled(v)
case 'B/s': return scaled(v) + '/s'
case 's': return duration(v)
case 'min': return duration(v * 60)
case '%': return `${v.toFixed(v < 10 ? 1 : 0)}%`
case '°C': return `${v.toFixed(0)}°C`
case 'rpm': return `${Math.round(v)} rpm`
case undefined: case '': return String(Math.round(v * 100) / 100)
default: return `${Math.round(v * 10) / 10} ${unit}`
}
}
// How full the scale is, 0–1, when the sensor has one. Temperatures use the hardware's
// own critical threshold as the top of the scale.
export function fraction(s: Sensor): number | null {
if (typeof s.value !== 'number') return null
const max = s.max ?? s.crit ?? s.warn, min = s.min ?? 0
if (max === undefined || max <= min) return null
return Math.min(1, Math.max(0, (s.value - min) / (max - min)))
}
export function severity(s: Sensor): '' | 'warn' | 'bad' {
if (typeof s.value !== 'number') return ''
if (s.crit !== undefined && s.value >= s.crit) return 'bad'
if (s.warn !== undefined && s.value >= s.warn) return 'warn'
if (s.kind === 'usage' && s.id !== 'ups.battery') return s.value >= 95 ? 'bad' : s.value >= 85 ? 'warn' : ''
return ''
}