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,87 @@
|
||||
// Prebuilt widgets over the sensor API. A widget is a spec — a type and a selector —
|
||||
// and nothing else; the same spec renders inside the dashboard or alone in an iframe.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Sparkline from './Sparkline'
|
||||
import { Sensor, Source, fetchHistory, fmtValue, fraction, severity } from './sensors'
|
||||
|
||||
export type WidgetType = 'stat' | 'gauge' | 'bar' | 'spark' | 'list'
|
||||
export interface WidgetSpec { id: string; type: WidgetType; sensor: string; title?: string }
|
||||
export const WIDGET_TYPES: { id: WidgetType; label: string; many?: boolean }[] = [
|
||||
{ id: 'stat', label: 'Number' }, { id: 'gauge', label: 'Gauge' }, { id: 'bar', label: 'Bar' },
|
||||
{ id: 'spark', label: 'Sparkline' }, { id: 'list', label: 'List (pattern, e.g. fan.*)', many: true },
|
||||
]
|
||||
|
||||
// What the stream hands a widget: one sensor, a map of them (a glob), or null (no such sensor here).
|
||||
export type WidgetData = Sensor | Record<string, Sensor> | null | undefined
|
||||
const isSensor = (d: WidgetData): d is Sensor => !!d && typeof (d as Sensor).id === 'string'
|
||||
|
||||
const MAX_POINTS = 300
|
||||
|
||||
function Gauge({ s }: { s: Sensor }) {
|
||||
const f = fraction(s) ?? 0, sev = severity(s)
|
||||
const R = 40, C = Math.PI * R // half circle
|
||||
return (
|
||||
<svg viewBox="0 0 100 56" className="gauge-svg" role="img" aria-label={`${s.name} ${fmtValue(s.value, s.unit)}`}>
|
||||
<path d="M10 50 A40 40 0 0 1 90 50" fill="none" stroke="var(--track)" strokeWidth="9" strokeLinecap="round" />
|
||||
<path d="M10 50 A40 40 0 0 1 90 50" fill="none" stroke={sev === 'bad' ? 'var(--bad)' : sev === 'warn' ? 'var(--s2)' : 'var(--s1)'} strokeWidth="9" strokeLinecap="round"
|
||||
strokeDasharray={`${f * C} ${C}`} style={{ transition: 'stroke-dasharray .3s' }} />
|
||||
<text x="50" y="47" textAnchor="middle" className="w-gauge-v">{fmtValue(s.value, s.unit)}</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Spark({ s, src }: { s: Sensor; src?: Source }) {
|
||||
const pts = useRef<number[]>([])
|
||||
const lastT = useRef(0)
|
||||
const [, bump] = useState(0)
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
pts.current = []; lastT.current = 0
|
||||
fetchHistory(s.id, src).then((h) => { if (!stop && h) { pts.current = h.v.slice(-MAX_POINTS); lastT.current = h.t[h.t.length - 1] ?? 0; bump((n) => n + 1) } })
|
||||
return () => { stop = true }
|
||||
}, [s.id, src?.machine, src?.token]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (typeof s.value === 'number' && s.t > lastT.current) {
|
||||
lastT.current = s.t
|
||||
pts.current.push(s.value)
|
||||
if (pts.current.length > MAX_POINTS) pts.current.shift()
|
||||
}
|
||||
return <Sparkline series={[{ name: s.name, values: pts.current, color: 'var(--s1)' }]} max={s.unit === '%' ? 100 : s.max} format={(v) => fmtValue(v, s.unit)} height={40} />
|
||||
}
|
||||
|
||||
export function Widget({ spec, data, src }: { spec: WidgetSpec; data: WidgetData; src?: Source }) {
|
||||
const title = spec.title || (isSensor(data) ? data.name : spec.sensor)
|
||||
let body
|
||||
if (data === undefined) body = <div className="value muted">…</div>
|
||||
else if (data === null) body = <div className="sub">no sensor <span className="mono">{spec.sensor}</span> on this machine</div>
|
||||
else if (!isSensor(data)) {
|
||||
const rows = Object.values(data).sort((a, b) => a.id.localeCompare(b.id))
|
||||
body = rows.length === 0 ? <div className="sub">nothing matches <span className="mono">{spec.sensor}</span></div> : (
|
||||
<div className="w-list">
|
||||
{rows.map((s) => {
|
||||
const f = fraction(s)
|
||||
return (
|
||||
<div className="w-row" key={s.id}>
|
||||
<span className="k" title={s.id}>{s.name}</span>
|
||||
<span className={'num ' + severity(s)}>{fmtValue(s.value, s.unit)}</span>
|
||||
{f !== null && <div className="meter"><div className={'fill ' + severity(s)} style={{ width: `${f * 100}%` }} /></div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
} else if (spec.type === 'gauge' && fraction(data) !== null) body = <Gauge s={data} />
|
||||
else {
|
||||
const f = fraction(data)
|
||||
body = (
|
||||
<>
|
||||
<div className={'value ' + severity(data)}>{fmtValue(data.value, data.unit)}</div>
|
||||
{spec.type === 'bar' && f !== null && <div className="meter"><div className={'fill ' + severity(data)} style={{ width: `${f * 100}%` }} /></div>}
|
||||
{spec.type === 'spark' && <Spark s={data} src={src} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <><div className="label" title={spec.sensor}>{title}</div>{body}</>
|
||||
}
|
||||
|
||||
// The one query that feeds a set of widgets: each spec's id → its selector.
|
||||
export const shapeOf = (specs: WidgetSpec[]) => (specs.length ? Object.fromEntries(specs.map((w) => [w.id, w.sensor])) : null)
|
||||
Reference in New Issue
Block a user