Each machine keeps 300 points per curve whatever the window, so memory doesn't follow it; a wider window means wider points, each the average of its bucket rather than one instant out of it. The hub passes the setting to every machine it manages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
110 lines
5.9 KiB
TypeScript
110 lines
5.9 KiB
TypeScript
// 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'
|
|
|
|
// "cpu.core.*.usage" → "CPU core usage", "fan.*" → "Fans": what a pattern is called when nobody named it.
|
|
const FAMILY_TITLE: Record<string, string> = { cpu: 'CPU', mem: 'Memory', swap: 'Swap', temp: 'Temperatures', fan: 'Fans', net: 'Network', disk: 'Disks', fs: 'Filesystems', ups: 'UPS', zfs: 'ZFS', smart: 'Drive health', service: 'Services', services: 'Services', container: 'Containers', containers: 'Containers', updates: 'Updates', load: 'Load' }
|
|
export function patternTitle(sel: string): string {
|
|
const [first, ...rest] = sel.split('.')
|
|
const tail = rest.filter((x) => !/[*?[]/.test(x)).join(' ').replace(/_/g, ' ')
|
|
return [FAMILY_TITLE[first] ?? first, tail].filter(Boolean).join(' ')
|
|
}
|
|
|
|
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 stride = useRef(0) // how far apart the machine's own points are, as they arrive
|
|
const [, bump] = useState(0)
|
|
useEffect(() => {
|
|
let stop = false
|
|
pts.current = []; lastT.current = 0; stride.current = 0
|
|
fetchHistory(s.id, src).then((h) => {
|
|
if (stop || !h) return
|
|
pts.current = h.v.slice(-MAX_POINTS); lastT.current = h.t[h.t.length - 1] ?? 0
|
|
const t = h.t.slice(-MAX_POINTS)
|
|
const gaps = t.slice(1).map((x, i) => x - t[i]).sort((a, b) => a - b)
|
|
stride.current = gaps.length ? gaps[gaps.length >> 1] : 0 // the median: one long gap is a restart, not the spacing
|
|
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) {
|
|
// a new point once a stride has passed; until then the newest point keeps being refined,
|
|
// so a wide window's curve isn't squeezed by a fast stream of live values
|
|
if (pts.current.length && s.t - lastT.current < stride.current) {
|
|
pts.current[pts.current.length - 1] = s.value
|
|
} else {
|
|
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 : /[*?[]/.test(spec.sensor) ? patternTitle(spec.sensor) : 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)
|