Files
hamlab/web/src/sensors.ts
T

126 lines
6.4 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, but never says why it failed — so ask once, plainly
fetch(url(srcRef.current, '/api/sensors/query', { q: key }), { credentials: 'same-origin' }).then(async (r) => {
if (r.ok) return
const why = (await r.json().catch(() => ({}))).error
setError(r.status === 401 ? 'the token was rejected' : r.status === 429 ? 'too many live streams are open' : r.status === 404 ? 'no such machine' : r.status === 502 ? 'that machine is unreachable' : why || `the API answered ${r.status}`)
}).catch(() => setError("can't reach Ham Lab"))
}
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 ''
}