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:
co-authored by
Claude Fable 5.1
parent
468f45251d
commit
b05a053920
@@ -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 ''
|
||||
}
|
||||
Reference in New Issue
Block a user