- 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>
119 lines
5.8 KiB
TypeScript
119 lines
5.8 KiB
TypeScript
// 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 ''
|
||
}
|