// 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
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')}
Anomaly detection (statistical, no ML)
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
{err &&
{err}
}
| name | email | phone | channels | groups |
status | actions |
{recipients.map(r => (
| {r.name} |
{r.email || —} |
{r.phone || —} |
{(r.channels || []).map(c => {c})} |
{(r.groups || ['*']).map(g => {g})} |
{r.enabled ? 'enabled' : 'disabled'}
|
{confirmDelete === r.id ? (
) : (
)}
|
))}
{recipients.length === 0 && (
|
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 (
);
}
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}
| node | status | since | level | last notified | actions |
{incidents.map(i => {
const n = byId[i.nodeId] || {};
return (
| {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
| when | channel | to | node | subject | result |
{(entries || []).map((e, i) => (
| {window.fmtRel(e.ts)} |
{e.channel} |
{e.to || '—'} |
{e.nodeId || (e.test ? '(test)' : '—')} |
{e.subject} |
{e.ok
? ok
: {e.retryable ? 'retryable' : 'failed'}}
|
))}
{(entries || []).length === 0 && (
| 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 });