The full switch: Monitors and Disks read only the sensor API; /api/metrics retired

- stock Monitors modules fed by one sensor stream, the catalog and one batch of
  histories (GET /api/sensors/history?ids=…), through sampleAdapter.ts
- the stream closes while the page is hidden and refills its curves on return
- Disks tab live rates from the stream
- new sensors: hamlab.sample_interval, ups.last_event
- stream: asking for about the sampling rate means every sample
- NUT runtime rounded at the source

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 07:52:14 -07:00
co-authored by Claude Fable 5.1
parent f01175ceca
commit 4ddf10be51
13 changed files with 283 additions and 173 deletions
+28 -7
View File
@@ -32,24 +32,45 @@ export async function fetchHistory(id: string, src?: Source): Promise<{ t: numbe
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 } {
export async function fetchHistories(ids: string, src?: Source): Promise<{ id: string; unit?: string; t: number[]; v: number[] }[]> {
const r = await fetch(url(src, '/api/sensors/history', { ids }), { credentials: 'same-origin' })
if (!r.ok) throw new Error(r.statusText)
return (await r.json()).series
}
export interface StreamOpts { src?: Source; everyMs?: number; full?: boolean }
// useSensorStream keeps a shaped query answered: the object comes back filled (with whole
// sensors unless full is false) and is replaced on every sample. One stream serves any
// number of widgets. It closes while the page is hidden — a phone in a pocket shouldn't
// hold a stream open — and `resumed` counts the times it came back, so a caller that
// keeps history knows there is a gap to refill.
export function useSensorStream<T = unknown>(shape: Shape | null, opts: StreamOpts = {}): { data: T | null; error: string; resumed: number } {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState('')
const [resumed, setResumed] = useState(0)
const [visible, setVisible] = useState(() => document.visibilityState === 'visible')
const key = shape === null ? '' : JSON.stringify(shape)
const srcKey = `${src?.machine ?? '·'}|${src?.token ?? ''}|${everyMs ?? ''}`
const { src, everyMs, full = true } = opts
const srcKey = `${src?.machine ?? '·'}|${src?.token ?? ''}|${everyMs ?? ''}|${full}`
const srcRef = useRef(src); srcRef.current = src
useEffect(() => {
const onVis = () => { const v = document.visibilityState === 'visible'; setVisible(v); if (v) setResumed((n) => n + 1) }
document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
useEffect(() => {
if (!key) { setData(null); return }
const params: Record<string, string> = { q: key, full: '1' }
if (!visible) return
const params: Record<string, string> = { q: key }
if (full) params.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 }
}, [key, srcKey, visible]) // eslint-disable-line react-hooks/exhaustive-deps
return { data, error, resumed }
}
// ---- formatting, by unit ----