// Notifications view — providers, rules, recipients, test-send, incidents, log. // All mutations go through admin auth (window.adminFetch). All hooks are // declared above any early-return so hook order stays stable across // the "auth needed" → "signed in" transition (this has crashed admin.jsx // twice when the rule was violated). const NOTIF_INPUT = { width: '100%', padding: '5px 9px', background: 'var(--surface)', border: '1px solid var(--border-2)', borderRadius: 2, color: 'var(--text)', fontFamily: 'var(--mono)', fontSize: 12, outline: 'none', }; function SecretInput({ name, label, status, onSave, onClear }) { const [val, setVal] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const configured = status?.configured; const envOverride = status?.env; async function save() { if (!val.trim()) return; setBusy(true); setErr(null); try { await onSave(name, val); setVal(''); } catch (e) { setErr(e.message || 'save failed'); } finally { setBusy(false); } } async function clear() { setBusy(true); setErr(null); try { await onClear(name); } catch (e) { setErr(e.message || 'clear failed'); } finally { setBusy(false); } } const dirty = val.trim().length > 0; return (
setVal(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); save(); } }} /> {configured && status?.file && ( )}
{err &&
{err}
}
); } function ProvidersCard({ data, onConfigSave, onSecretSave, onSecretClear }) { const [emailEnabled, setEmailEnabled] = React.useState(data?.providers?.email?.enabled || false); const [emailFrom, setEmailFrom] = React.useState(data?.providers?.email?.from || ''); const [smsEnabled, setSmsEnabled] = React.useState(data?.providers?.sms?.enabled || false); const [smsFrom, setSmsFrom] = React.useState(data?.providers?.sms?.fromNumber || ''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); React.useEffect(() => { setEmailEnabled(data?.providers?.email?.enabled || false); setEmailFrom(data?.providers?.email?.from || ''); setSmsEnabled(data?.providers?.sms?.enabled || false); setSmsFrom(data?.providers?.sms?.fromNumber || ''); }, [data?.providers?.email?.enabled, data?.providers?.email?.from, data?.providers?.sms?.enabled, data?.providers?.sms?.fromNumber]); async function saveConfig() { setBusy(true); setErr(null); try { await onConfigSave({ providers: { email: { enabled: emailEnabled, from: emailFrom }, sms: { enabled: smsEnabled, fromNumber: smsFrom }, } }); } catch (e) { setErr(e.message); } finally { setBusy(false); } } const ps = data?.providersStatus || {}; return (

Providers

Resend for email · Twilio for SMS
email · resend {ps.email?.ready ? ready : not ready}
setEmailFrom(e.target.value)} />
sms · twilio {ps.sms?.ready ? ready : not ready}
setSmsFrom(e.target.value)} />
Env vars override the values saved via UI. Files live in backend/ with mode 0600. {err && {err}}
); } function RulesCard({ data, onConfigSave }) { const r = data?.rules || {}; const [form, setForm] = React.useState(r); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); React.useEffect(() => { setForm(r); }, [JSON.stringify(r)]); function set(k, v) { setForm(f => ({ ...f, [k]: v })); } function setQH(k, v) { setForm(f => ({ ...f, quietHours: { ...f.quietHours, [k]: v } })); } async function save() { setBusy(true); setErr(null); try { await onConfigSave({ rules: form }); } catch (e) { setErr(e.message); } finally { setBusy(false); } } const num = (k, label, hint) => (
set(k, Number(e.target.value))} />
); return (

Rules

how the engine decides when and how much to send
{num('consecutiveFailures', 'consecutive failures', 'debounce flaps')} {num('cooldownMinutes', 'cooldown (min)', 'between notifications per incident')} {num('escalateAfterMinutes', 'escalate after (min)', 'email → email+SMS')} {num('reminderIntervalMinutes', 'reminder interval (min)', 'while still down')} {num('maxEmailsPerHour', 'max emails / hour', 'global rate cap')} {num('maxSmsPerDay', 'max SMS / day', 'global rate cap')}
setQH('startHour', Number(e.target.value))} /> setQH('endHour', Number(e.target.value))} />
Anomaly detection (statistical, no ML)
set('anomalySigma', Number(e.target.value))} />
set('anomalyMinSamples', Number(e.target.value))} />
set('anomalyConsec', Number(e.target.value))} />
Baseline uses rolling 7d samples at the current hour-of-day (UTC).
Trigger statuses: {(form.triggerStatuses || []).join(', ')} {err && {err}}
); } function RecipientsCard({ data, groups, onAdd, onPatch, onDelete }) { const [form, setForm] = React.useState({ name:'', email:'', phone:'', channels:['email'], groups:['*'], enabled:true }); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const [editing, setEditing] = React.useState(null); // recipient being edited inline const [confirmDelete, setConfirmDelete] = React.useState(null); React.useEffect(() => { if (!confirmDelete) return; const t = setTimeout(() => setConfirmDelete(null), 4000); return () => clearTimeout(t); }, [confirmDelete]); const recipients = data?.recipients || []; function toggleList(list, item) { return list.includes(item) ? list.filter(x => x !== item) : [...list, item]; } async function submit(e) { e.preventDefault(); setBusy(true); setErr(null); try { await onAdd(form); setForm({ name:'', email:'', phone:'', channels:['email'], groups:['*'], enabled:true }); } catch (e2) { setErr(e2.message); } finally { setBusy(false); } } return (

Recipients

{recipients.length} configured
setForm({ ...form, name: e.target.value })} />
setForm({ ...form, email: e.target.value })} />
setForm({ ...form, phone: e.target.value })} />
{(groups || []).map(g => ( ))}
{err &&
{err}
}
{recipients.map(r => ( ))} {recipients.length === 0 && ( )}
nameemailphonechannelsgroups statusactions
{r.name} {r.email || } {r.phone || } {(r.channels || []).map(c => {c})} {(r.groups || ['*']).map(g => {g})} {r.enabled ? 'enabled' : 'disabled'}
{confirmDelete === r.id ? ( ) : ( )}
No recipients yet — add one above to start receiving alerts.
); } function TestSendCard({ onSend, providersStatus }) { const [channel, setChannel] = React.useState('email'); const [to, setTo] = React.useState(''); const [busy, setBusy] = React.useState(false); const [result, setResult] = React.useState(null); const ready = channel === 'email' ? providersStatus?.email?.ready : providersStatus?.sms?.ready; async function send(e) { e.preventDefault(); setBusy(true); setResult(null); try { const r = await onSend(channel, to.trim()); setResult(r); } catch (e2) { setResult({ ok: false, error: e2.message }); } finally { setBusy(false); } } return (

Test send

counts against rate limits
setTo(e.target.value)} />
{!ready && (
⚠ {channel.toUpperCase()} provider not ready. Check: "enabled" toggle, "from" address, and saved API key above.
)} {result && (
{result.ok ? '✓ sent' : `✗ ${result.error}${result.retryable ? ' (retryable)' : ''}`}
)}
); } function IncidentsCard({ incidents, nodes, onAck }) { const byId = React.useMemo(() => { const m = {}; for (const n of nodes || []) m[n.id] = n; return m; }, [nodes]); if (!incidents || incidents.length === 0) { return (

Active incidents

0
No active incidents — all quiet.
); } return (

Active incidents

{incidents.length}
{incidents.map(i => { const n = byId[i.nodeId] || {}; return ( ); })}
nodestatussincelevellast notifiedactions
{n.displayName || n.hostname || i.nodeId} {i.lastStatus} {window.fmtRel(i.startedAt)} {i.level === 0 && tracking} {i.level === 1 && email} {i.level === 2 && email + sms} {i.acked && ack} {i.snoozedUntil && snoozed} {i.lastNotifiedAt ? window.fmtRel(i.lastNotifiedAt) : '—'}
{!i.acked && }
); } function LogCard({ entries }) { return (

Recent sends

{(entries || []).length} entries · newest first
{(entries || []).map((e, i) => ( ))} {(entries || []).length === 0 && ( )}
whenchanneltonodesubjectresult
{window.fmtRel(e.ts)} {e.channel} {e.to || '—'} {e.nodeId || (e.test ? '(test)' : '—')} {e.subject} {e.ok ? ok : {e.retryable ? 'retryable' : 'failed'}}
No sends yet.
); } function Notifications({ runtimeNodes }) { const auth = window.useAdminAuth(); const [data, setData] = React.useState(null); // /api/admin/notifications payload const [incidents, setIncidents] = React.useState([]); const [log, setLog] = React.useState([]); const [loadErr, setLoadErr] = React.useState(null); const groups = React.useMemo( () => (window.deriveGroups ? window.deriveGroups(runtimeNodes || []) : []), [runtimeNodes] ); async function loadAll() { setLoadErr(null); try { const [cfg, inc, lg] = await Promise.all([ window.adminFetch('/api/admin/notifications'), window.adminFetch('/api/admin/notifications/incidents'), window.adminFetch('/api/admin/notifications/log?limit=100'), ]); setData(cfg); setIncidents(inc); setLog(lg); } catch (e) { if (e.message === 'unauthorized') { setData(null); } else setLoadErr(e.message); } } React.useEffect(() => { if (auth.authed) loadAll(); else setData(null); }, [auth.authed]); React.useEffect(() => { if (!auth.authed) return; const t = setInterval(() => { window.adminFetch('/api/admin/notifications/incidents').then(setIncidents).catch(()=>{}); window.adminFetch('/api/admin/notifications/log?limit=100').then(setLog).catch(()=>{}); }, 15000); return () => clearInterval(t); }, [auth.authed]); async function saveConfig(patch) { await window.adminFetch('/api/admin/notifications/config', { method:'PUT', body: JSON.stringify(patch) }); await loadAll(); } async function saveSecret(name, value) { await window.adminFetch('/api/admin/notifications/secrets', { method: 'PUT', body: JSON.stringify({ key: name, value }), }); await loadAll(); } async function clearSecret(name) { await window.adminFetch(`/api/admin/notifications/secrets/${name}`, { method: 'DELETE' }); await loadAll(); } async function addRecipient(patch) { await window.adminFetch('/api/admin/notifications/recipients', { method: 'POST', body: JSON.stringify(patch), }); await loadAll(); } async function patchRecipient(rid, patch) { await window.adminFetch(`/api/admin/notifications/recipients/${rid}`, { method: 'PATCH', body: JSON.stringify(patch), }); await loadAll(); } async function deleteRecipient(rid) { await window.adminFetch(`/api/admin/notifications/recipients/${rid}`, { method: 'DELETE' }); await loadAll(); } async function testSend(channel, to) { return await window.adminFetch('/api/admin/notifications/test', { method: 'POST', body: JSON.stringify({ channel, to }), }); } async function ackIncident(node_id, snoozeMinutes) { const body = snoozeMinutes ? JSON.stringify({ snoozeMinutes }) : null; await window.adminFetch(`/api/admin/notifications/incidents/${node_id}/ack`, { method: 'POST', body, }); const inc = await window.adminFetch('/api/admin/notifications/incidents'); setIncidents(inc); } // Early returns must come AFTER all hooks above. if (!auth.authed) { return
; } if (loadErr) return
Failed to load: {loadErr}
; if (!data) return
Loading…
; return (
); } Object.assign(window, { Notifications });