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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user