Files
hamlab/web/src/sensors.ts
T
Mitchell-JohnsonandClaude Fable 5.1 4ddf10be51 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>
2026-09-19 07:52:14 -07:00

119 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
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 { 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 }
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, visible]) // eslint-disable-line react-hooks/exhaustive-deps
return { data, error, resumed }
}
// ---- 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 ''
}