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
+29
-3
@@ -3,6 +3,9 @@ import { api, DashPrefs, Sample, Series, Temp } from './api'
|
||||
import Sparkline from './Sparkline'
|
||||
import { Loading, Spinner, toast } from './ui'
|
||||
import { fmtMs, useRefreshMs } from './prefs'
|
||||
import { useSensorStream } from './sensors'
|
||||
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
|
||||
import WidgetPicker from './WidgetPicker'
|
||||
|
||||
const MAX = 300
|
||||
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
||||
@@ -27,7 +30,7 @@ const byId = (id: string) => MODULES.find((m) => m.id === id)
|
||||
const DEFAULT_INTERVAL = 2000
|
||||
const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
|
||||
|
||||
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number }
|
||||
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
|
||||
|
||||
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
|
||||
function normalize(p: DashPrefs): Layout {
|
||||
@@ -35,9 +38,10 @@ function normalize(p: DashPrefs): Layout {
|
||||
const order = (p.order ?? []).filter((id) => known.has(id))
|
||||
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
|
||||
const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL
|
||||
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv }
|
||||
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv,
|
||||
widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') }
|
||||
}
|
||||
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs })
|
||||
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets })
|
||||
const fmtWindow = (ms: number) => { const s = Math.round((MAX * ms) / 1000); return s >= 60 ? `${Math.round(s / 60)} min` : `${s} s` }
|
||||
|
||||
// ---- formatting ----
|
||||
@@ -337,6 +341,10 @@ export default function Dashboard() {
|
||||
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
|
||||
}, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
|
||||
const [picking, setPicking] = useState(false)
|
||||
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
|
||||
|
||||
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
||||
|
||||
const edit: Edit = {
|
||||
@@ -396,6 +404,24 @@ export default function Dashboard() {
|
||||
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{(layout.widgets.length > 0 || editing) && (
|
||||
<div className="kpis widgets">
|
||||
{layout.widgets.map((w, i) => (
|
||||
<div className={'tile w-' + w.type} key={w.id}>
|
||||
{editing && (
|
||||
<div className="mod-tools">
|
||||
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
|
||||
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
|
||||
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
|
||||
</div>
|
||||
)}
|
||||
<Widget spec={w} data={live.data ? live.data[w.id] : undefined} />
|
||||
</div>
|
||||
))}
|
||||
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
|
||||
</div>
|
||||
)}
|
||||
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
||||
{visible('card').length > 0 && (
|
||||
<div className="cards">
|
||||
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// One widget, alone, for an iframe in someone else's dashboard (Glance, Home Assistant…):
|
||||
// /embed/widget?type=gauge&sensor=cpu.usage&token=<read-only token>
|
||||
// &machine=grid &title=… &theme=dark|light|cyberpunk|retro &every=<ms> &bare=1
|
||||
// It stays live over the sensor stream. The token must be a read-only sensor token; a
|
||||
// full-control token is refused in a URL by design.
|
||||
import { useSensorStream, Source } from './sensors'
|
||||
import { Widget, WidgetData, WidgetSpec, WidgetType, WIDGET_TYPES } from './widgets'
|
||||
import { applyTheme } from './themes'
|
||||
|
||||
const p = new URLSearchParams(location.search)
|
||||
if (p.get('theme')) applyTheme(p.get('theme')!)
|
||||
|
||||
export default function Embed() {
|
||||
const sensor = p.get('sensor') ?? ''
|
||||
const asked = p.get('type') as WidgetType | null
|
||||
const type: WidgetType = /[*?[]/.test(sensor) ? 'list' : asked && WIDGET_TYPES.some((t) => t.id === asked && !t.many) ? asked : 'stat'
|
||||
const spec: WidgetSpec = { id: 'w', type, sensor, title: p.get('title') ?? undefined }
|
||||
const src: Source = { machine: p.get('machine') || null, token: p.get('token') ?? undefined }
|
||||
const every = Number(p.get('every')) || undefined
|
||||
const live = useSensorStream<{ w: WidgetData }>(sensor ? { w: sensor } : null, src, every)
|
||||
return (
|
||||
<div className={'embed' + (p.get('bare') === '1' ? ' bare' : '')}>
|
||||
{!sensor ? <div className="error">embed needs ?sensor=</div> : (
|
||||
<div className={'tile w-' + type}>
|
||||
<Widget spec={spec} data={live.data ? live.data.w : undefined} src={src} />
|
||||
{live.error && !live.data && <div className="sub">{src.token ? 'waiting for the stream — check the token' : 'no token given'}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+19
-2
@@ -31,7 +31,8 @@ function Preview({ vars }: { vars: Record<string, string> }) {
|
||||
export default function Settings({ me }: { me: Me }) {
|
||||
const [theme, setTheme] = useState(currentThemeId())
|
||||
const [refresh, setRefresh] = useState(getRefreshMs())
|
||||
const [tokens, setTokens] = useState<{ name: string; created: string }[] | null>(null)
|
||||
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string }[] | null>(null)
|
||||
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
|
||||
const [cur, setCur] = useState('')
|
||||
const [pw1, setPw1] = useState('')
|
||||
const [pw2, setPw2] = useState('')
|
||||
@@ -97,16 +98,32 @@ export default function Settings({ me }: { me: Me }) {
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||
<section className="card">
|
||||
<div className="muted small" style={{ marginBottom: 8 }}>Tokens let another Ham Lab (a hub) manage this host. They're issued when a hub adds this machine and revoked when it removes it.</div>
|
||||
<div className="muted small" style={{ marginBottom: 8 }}>A <b>full</b> token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A <b>read-only</b> token can only read sensors — that's the one to give a dashboard.</div>
|
||||
{tokens === null && <Loading label="Loading…" />}
|
||||
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
|
||||
{tokens && tokens.map((t) => (
|
||||
<div className="row" key={t.name}>
|
||||
<span className="k mono">{t.name}</span>
|
||||
<span className="badge" style={{ marginLeft: 0 }}>{t.scope ? 'read-only' : 'full'}</span>
|
||||
<span className="muted small">issued {new Date(t.created).toLocaleString()}</span>
|
||||
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
|
||||
</div>
|
||||
))}
|
||||
{fresh && (
|
||||
<div className="notice attn" style={{ marginTop: 8 }}>
|
||||
<div>Token for <b>{fresh.name}</b> — shown once, copy it now:</div>
|
||||
<div className="mono small" style={{ wordBreak: 'break-all', userSelect: 'all', margin: '6px 0' }}>{fresh.token}</div>
|
||||
<Btn className="btn" onClick={async () => { try { await navigator.clipboard.writeText(fresh.token); toast('copied') } catch { toast('select and copy it by hand', 'err') } }}>Copy</Btn>{' '}
|
||||
<Btn className="btn" onClick={() => setFresh(null)}>Done</Btn>
|
||||
</div>
|
||||
)}
|
||||
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||
<Btn className="btn" onClick={async () => {
|
||||
const name = window.prompt('Name for the read-only token (what will use it)', 'glance')?.trim()
|
||||
if (!name) return
|
||||
try { setFresh(await api.createReadToken(name)); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
||||
}}>+ Read-only sensor token</Btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">About</h2></div>
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ const FolderIcon = (
|
||||
)
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
|
||||
{ id: 'dashboard', label: 'Monitors', icon: '◔' },
|
||||
{ id: 'procs', label: 'Processes', icon: '≡' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '◈' },
|
||||
@@ -207,7 +207,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'dashboard' ? 'Monitors' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { wsURL } from './api'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Sensor, fetchCatalog, fmtValue } from './sensors'
|
||||
import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets'
|
||||
import { Btn, Loading } from './ui'
|
||||
|
||||
// Pick a sensor from this machine's catalog (or type a pattern) and how to draw it.
|
||||
export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec) => void; onClose: () => void }) {
|
||||
const [cat, setCat] = useState<Sensor[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sensor, setSensor] = useState('')
|
||||
const [type, setType] = useState<WidgetType>('stat')
|
||||
const [title, setTitle] = useState('')
|
||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
const shown = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase()
|
||||
return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f))
|
||||
}, [cat, filter])
|
||||
const pattern = /[*?[]/.test(sensor)
|
||||
const pick = (id: string) => { setSensor(id); if (/[*?[]/.test(id)) setType('list') }
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Add a widget</h3>
|
||||
<input autoFocus value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter sensors — cpu, temp, fan, enp3s0…" />
|
||||
{err && <div className="error">{err}</div>}
|
||||
{!cat && !err && <Loading label="Reading the catalog…" />}
|
||||
{cat && (
|
||||
<div className="picker-list">
|
||||
{shown.map((s) => (
|
||||
<button key={s.id} className={'picker-row' + (s.id === sensor ? ' active' : '')} onClick={() => pick(s.id)}>
|
||||
<span className="mono">{s.id}</span><span className="muted num">{fmtValue(s.value, s.unit)}</span>
|
||||
</button>
|
||||
))}
|
||||
{shown.length === 0 && <div className="muted small" style={{ padding: 8 }}>No sensor matches.</div>}
|
||||
</div>
|
||||
)}
|
||||
<label className="muted">Sensor ID, or a pattern for a list (<span className="mono">fan.*</span>, <span className="mono">cpu.core.*.usage</span>)
|
||||
<input className="mono" value={sensor} onChange={(e) => pick(e.target.value.trim())} placeholder="cpu.usage" />
|
||||
</label>
|
||||
<div className="chips">
|
||||
{WIDGET_TYPES.filter((t) => !!t.many === pattern).map((t) => <button key={t.id} className={'chip' + (type === t.id ? ' on' : '')} onClick={() => setType(t.id)}>{t.label}</button>)}
|
||||
</div>
|
||||
<label className="muted">Title (optional)
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
|
||||
</label>
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!sensor} onClick={() => onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })}>Add</Btn>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-2
@@ -79,7 +79,7 @@ export interface Series {
|
||||
|
||||
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
|
||||
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
|
||||
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number }
|
||||
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] }
|
||||
|
||||
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
|
||||
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
|
||||
@@ -179,7 +179,8 @@ export const api = {
|
||||
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
|
||||
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
|
||||
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||||
tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true),
|
||||
tokens: () => req<{ name: string; created: string; scope?: string }[]>('GET', '/api/tokens', undefined, true),
|
||||
createReadToken: (name: string) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name }, true),
|
||||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||
uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true),
|
||||
saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),
|
||||
|
||||
+7
-5
@@ -1,17 +1,19 @@
|
||||
import React from 'react'
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { applyTheme, currentThemeId } from './themes'
|
||||
applyTheme(currentThemeId())
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import './styles.css'
|
||||
|
||||
// An embedded widget is its own small bundle: it never loads the console (or xterm).
|
||||
const embedded = location.pathname.startsWith('/embed')
|
||||
const Root = embedded ? lazy(() => import('./Embed')) : lazy(() => import('./App'))
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<Suspense fallback={null}><Root /></Suspense>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
if ('serviceWorker' in navigator && location.protocol === 'https:') {
|
||||
if (!embedded && 'serviceWorker' in navigator && location.protocol === 'https:') {
|
||||
window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) })
|
||||
}
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -393,3 +393,24 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.header .link, .header .icon-btn { min-height: 36px; display: inline-flex; align-items: center; }
|
||||
.mod-tools { margin-top: 0; }
|
||||
}
|
||||
|
||||
/* sensor widgets */
|
||||
.gauge-svg { width: 100%; max-width: 180px; display: block; margin: 0 auto; }
|
||||
.w-gauge-v { fill: var(--fg); font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.tile .value.warn, .w-row .num.warn { color: var(--s2); } .tile .value.bad, .w-row .num.bad { color: var(--bad); }
|
||||
.tile .meter { margin-top: 8px; }
|
||||
.tile.w-list { grid-column: span 2; }
|
||||
.w-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.w-row { display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; font-size: 13px; align-items: baseline; }
|
||||
.w-row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .w-row .meter { grid-column: 1 / 3; margin-top: 0; }
|
||||
.tile.add-widget { border-style: dashed; color: var(--accent2); min-height: 72px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.modal.picker { width: min(520px, 100%); max-height: 90vh; }
|
||||
.picker-list { overflow-y: auto; max-height: 34vh; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); }
|
||||
.picker-row { display: flex; justify-content: space-between; gap: 10px; width: 100%; padding: 7px 10px; text-align: left; font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||
.picker-row:hover, .picker-row.active { background: var(--bg3); } .picker-row.active { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
@media (max-width: 800px) { .tile.w-list { grid-column: 1 / -1; } }
|
||||
|
||||
/* a widget alone in an iframe */
|
||||
.embed { min-height: 100vh; padding: 10px; background: var(--bg2); box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; }
|
||||
.embed.bare { background: transparent; }
|
||||
.embed .tile { border: 0; padding: 0; background: none; }
|
||||
|
||||
@@ -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