// Alerts: rules over sensors, watched by this hub for itself and every machine it manages. import { useCallback, useEffect, useState } from 'react' import { api, AlertChannel, AlertEvent, AlertRule, AlertsInfo } from './api' import { accentFor } from './accents' import { Btn, Loading, askYes, toast, useEscape } from './ui' const OPS = ['>', '>=', '<', '<=', '==', '!='] const ago = (ms: number) => { const s = Math.max(0, (Date.now() - ms) / 1000); return s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : s < 129600 ? `${Math.round(s / 3600)} h` : `${Math.round(s / 86400)} d` } const patience = (s: number) => (s === 0 ? 'at once' : s < 120 ? `for ${s} s` : `for ${Math.round(s / 60)} min`) const show = (v: unknown) => (typeof v === 'number' ? String(Math.round(v * 10) / 10) : String(v)) const parse = (raw: string): number | boolean | string => (raw === 'true' ? true : raw === 'false' ? false : raw.trim() !== '' && !isNaN(Number(raw)) ? Number(raw) : raw) // A rule names the machines it watches by the name the hub knows them by; on screen each is // titled by its own hostname, like everywhere else. function RuleDialog({ rule, machines, title, onSave, onClose }: { rule: AlertRule; machines: string[]; title: (n: string) => string; onSave: (r: AlertRule) => void; onClose: () => void }) { const [r, setR] = useState({ ...rule, value: String(rule.value) }) const [hits, setHits] = useState('') useEscape(onClose) useEffect(() => { // what the selector matches right now, across the fleet if (!r.sensor.trim()) { setHits(''); return } const t = window.setTimeout(async () => { try { const res = await fetch('/api/fleet/sensors/query', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ machines: '*', query: { x: r.sensor.trim() } }) }) if (!res.ok) { setHits((await res.json().catch(() => ({}))).error ?? 'not a valid pattern'); return } const by = await res.json() as Record const n = Object.entries(by).map(([m, v]) => [m, v.error ? 0 : v.x && typeof v.x === 'object' ? Object.keys(v.x).length : v.x === null || v.x === undefined ? 0 : 1] as const) setHits(r.sensor.trim() === 'hamlab.reachable' ? 'every machine: whether it answers the hub' : n.every(([, c]) => c === 0) ? 'matches nothing right now' : 'matches ' + n.filter(([, c]) => c).map(([m, c]) => `${c} on ${m}`).join(', ')) } catch { setHits('') } }, 350) return () => clearTimeout(t) }, [r.sensor]) const all = !r.machines || r.machines === '*' const picked = all ? [] : r.machines.split(',').map((x) => x.trim()).filter(Boolean) return (
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onSave({ ...r, name: r.name.trim(), sensor: r.sensor.trim(), value: parse(r.value) }) }}>

{rule.name ? 'Edit rule' : 'New rule'}

{hits &&
{hits}
}
setR({ ...r, value: e.target.value })} placeholder="90 · true · unhealthy" spellCheck={false} autoCapitalize="off" /> for setR({ ...r, for_sec: Math.max(0, Number(e.target.value) || 0) })} style={{ width: 90 }} />s
{(['info', 'warn', 'crit'] as const).map((s) => )}
{machines.map((m) => )}
) } export default function Alerts({ machines, titleOf: title }: { machines: string[]; titleOf: (n: string) => string }) { const [a, setA] = useState(null) const [err, setErr] = useState('') const [editing, setEditing] = useState(null) const [chan, setChan] = useState({ type: 'ntfy', url: '' }) const load = useCallback(async () => { try { const r = await api.alerts(); setA({ ...r, rules: r.rules ?? [], channels: r.channels ?? [], events: r.events ?? [], firing: r.firing ?? [] }); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 5000); return () => clearInterval(t) }, [load]) if (!a) return
{err ?
{err}
: }
const save = async (patch: { rules?: AlertRule[]; channels?: AlertChannel[] }, done: string) => { try { await api.saveAlerts(patch); toast(done); window.dispatchEvent(new Event('hamlab:alerts')); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const saveRule = (r: AlertRule) => { const exists = a.rules.some((x) => x.id === r.id); save({ rules: exists ? a.rules.map((x) => (x.id === r.id ? r : x)) : [...a.rules, r] }, exists ? 'rule saved' : 'rule added'); setEditing(null) } const sevClass = (s: string) => (s === 'crit' ? 'bad' : s === 'warn' ? 'warn' : '') return (

Now

checked {a.checked > 0 ? `${ago(a.checked)} ago` : 'not yet'} · every {a.every_sec} s · all machines
{a.firing.length === 0 ?
✓ All clear — {a.rules.filter((r) => r.enabled).length} rules watching {machines.length + 1} machine{machines.length ? 's' : ''}
: (
{a.firing.map((f) => (
{f.rule}{title(f.machine)}
{f.sensor} = {show(f.value)}
for {ago(f.since)}
))}
)}

Rules

{a.rules.length === 0 &&
No rules. Nothing is being watched.
} {a.rules.map((r) => (
setEditing(r)}>
{r.name}{a.firing.some((f) => f.rule_id === r.id) && firing}
{r.sensor} {r.op} {show(r.value)} · {patience(r.for_sec)}{r.machines && r.machines !== '*' ? ` · ${r.machines}` : ''}
))}

Where alerts go

{a.channels.length > 0 && { try { await api.testAlerts(); toast('test sent') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Send a test}
Always here, and on the rail. To reach your phone: an ntfy topic URL (the ntfy app, from ntfy.sh or your own server), or a webhook — Home Assistant, Slack, anything that takes a POST. Sent when an alert fires and again when it clears.
{a.channels.map((c, i) =>
{c.type}{c.url} save({ channels: a.channels.filter((_, j) => j !== i) }, 'channel removed')}>Remove
)}
{ e.preventDefault(); if (!/^https?:\/\/./.test(chan.url.trim())) { toast('a channel needs an http(s) URL', 'err'); return } save({ channels: [...a.channels, { type: chan.type, url: chan.url.trim() }] }, 'channel added'); setChan({ ...chan, url: '' }) }}> setChan({ ...chan, url: e.target.value })} placeholder={chan.type === 'ntfy' ? 'https://ntfy.sh/your-secret-topic' : 'https://…/api/webhook/…'} spellCheck={false} autoCapitalize="off" />

History

the last {a.events.length}
{a.events.length === 0 &&
Nothing has fired yet.
} {a.events.map((e: AlertEvent, i) => (
{e.kind === 'firing' ? '▲' : '✓'} {e.rule} · {title(e.machine)} · {e.sensor} = {show(e.value)} {ago(e.t)} ago
))}
{editing && setEditing(null)} />}
) }