Add notification channels and a provider abstraction

Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 6292ad6708
commit b114251f45
42 changed files with 2200 additions and 611 deletions
+10
View File
@@ -826,6 +826,16 @@ details[open] > summary { margin-bottom: 8px; }
.pw-form { display: grid; gap: 12px; padding: 16px; }
.account-fold { border-top: 1px solid var(--border); padding-top: 12px; }
.account-fold .stacked-form { margin-top: 8px; }
/* Notification channels on the Account page: a row per channel, the kind and
address on the left and its actions under or beside them. Wrapping rather
than a fixed grid, because an address can be as long as a topic is secret. */
.channels { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
.channel { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
.channel.off .channel-main { opacity: 0.6; }
.channel-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; min-width: 0; }
.channel-main code { overflow-wrap: anywhere; }
.channel .row-actions { margin-top: 0; gap: 6px; }
.form-ok {
margin: 0; padding: 10px 12px;
background: var(--ok-soft); color: var(--ok);
+124 -67
View File
@@ -10,11 +10,17 @@ const view = () => document.getElementById('view-more');
// Rendered once per visit rather than on every poll, so a half-typed password
// is never wiped out from under you.
export function show() {
render();
export async function show() {
let providers = [];
try {
providers = await api.notifyProviders();
} catch {
/* the card says the server can send nothing, which is the safe reading */
}
render(providers);
}
function render() {
function render(providers) {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
@@ -24,7 +30,7 @@ function render() {
h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyForm(user),
notifyCard(user, providers),
...passwordSection(user, hasPassword),
@@ -59,79 +65,132 @@ function themePicker() {
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
}
// Where this user's pages go. The onboarding checklist's first step sends
// people here for it, and until now there was nothing here to send them to:
// the topic could only be set with curl or by an administrator.
//
// The topic is the whole address — the server it is published to is the
// install's one ntfy, set in the deployment and not something a user picks.
function notifyStatus(topic) {
return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.';
// Where this user's pages go. Each channel is one way of reaching them — an ntfy
// topic today, other kinds as the server grows them — and a page goes to every
// channel that is switched on. Which kinds exist is the server's to say: the
// list comes from /notify/providers, so a deployment with one kind configured
// shows no picker.
function notifyCard(user, providers) {
const root = h('div', { class: 'card pw-form' });
let channels = user.channels || [];
const label = (type) => providers.find((p) => p.type === type)?.label || type;
// Keep the cached user in step, so the onboarding checklist stops asking for
// this and the test button works without a reload.
const commit = (next) => {
channels = next;
state.me.user.channels = next;
draw();
};
const row = (c) => {
const known = providers.some((p) => p.type === c.type);
return h('li', { class: c.enabled ? 'channel' : 'channel off' },
h('div', { class: 'channel-main' },
h('strong', { text: label(c.type) }),
h('code', { text: c.address }),
!c.enabled && h('span', { class: 'muted small', text: 'off' }),
!known && h('span', { class: 'load-error small', text: 'this server cannot deliver this kind' })),
h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Send a test',
disabled: !known,
onclick: async (e) => {
e.target.disabled = true;
try {
await api.testChannel(user.id, c.id);
toast(`Sent. If nothing arrives, check the ${label(c.type)} address.`);
} catch (ex) {
toast(ex.message, 'error');
} finally {
e.target.disabled = !known;
}
},
}),
h('button', {
class: 'btn-sm', type: 'button', text: c.enabled ? 'Turn off' : 'Turn on',
onclick: async () => {
try {
const updated = await api.updateChannel(user.id, c.id, { enabled: !c.enabled });
commit(channels.map((x) => (x.id === c.id ? updated : x)));
} catch (ex) {
toast(ex.message, 'error');
}
},
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: async () => {
try {
await api.removeChannel(user.id, c.id);
commit(channels.filter((x) => x.id !== c.id));
} catch (ex) {
toast(ex.message, 'error');
}
},
})));
};
const draw = () => {
const enabled = channels.filter((c) => c.enabled).length;
clear(root,
h('p', { class: 'muted', text: channelStatus(channels.length, enabled, providers.length) }),
channels.length > 0 && h('ul', { class: 'channels' }, ...channels.map(row)),
providers.length > 0 && addChannelForm(user, providers, (created) => commit([...channels, created])));
};
draw();
return root;
}
function notifyForm(user) {
function channelStatus(total, enabled, providerCount) {
if (providerCount === 0) {
return 'This server has no way of sending notifications set up, so it cannot page anyone. ' +
'An administrator configures that in the deployment.';
}
if (total === 0) return 'No channels yet — pages go to the team’s fallback instead of you.';
if (enabled === 0) return 'Every channel is off — pages go to the team’s fallback instead of you.';
return `Pages reach you on ${enabled} channel${enabled === 1 ? '' : 's'}.`;
}
function addChannelForm(user, providers, onAdded) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const topic = h('input', {
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false,
value: user.ntfy_topic || '',
placeholder: 'terdut-a7f3c91e',
const type = h('select', { name: 'type', 'aria-label': 'Kind of channel' },
...providers.map((p) => h('option', { value: p.type, text: p.label })));
const address = h('input', {
name: 'address', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false, required: true,
});
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
const labelText = h('span');
const hint = h('p', { class: 'muted small' });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add channel' });
// The field's name and the advice under it belong to the provider: an ntfy
// topic is a secret, an email address is not, and the page should not guess.
const describe = () => {
const p = providers.find((x) => x.type === type.value);
labelText.textContent = p?.address_label || 'Address';
hint.textContent = p?.address_hint || '';
};
type.addEventListener('change', describe);
describe();
const form = h('form', { class: 'stacked-form' },
h('label', {},
h('span', { text: 'ntfy topic' }),
topic),
h('p', { class: 'muted small' },
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
'reach your phone. Leave it empty and they page the team’s fallback ',
'topic instead.'),
// Worth saying plainly: people reach for their own name, and the topic is
// the only thing standing between a stranger and their pages.
h('p', { class: 'muted small' },
'Anyone who knows the topic can read your pages and publish to it, so ',
'pick something unguessable rather than your name.'),
providers.length > 1 && h('label', {}, h('span', { text: 'Kind' }), type),
h('label', {}, labelText, address),
hint,
err,
submit,
);
const status = h('p', { class: 'muted', text: notifyStatus(user.ntfy_topic) });
const summary = h('summary', { text: user.ntfy_topic ? 'Change topic' : 'Set a topic' });
const details = h('details', { class: 'account-fold' }, summary, form);
// Only offered once a topic is saved: the test publishes to whatever the
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
test.disabled = true;
try {
await api.testNotification();
toast('Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.');
} catch (ex) {
toast(ex.message, 'error');
} finally {
test.disabled = false;
}
},
});
submit);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
submit.disabled = true;
try {
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
// Keep the cached user in step, so the onboarding checklist stops
// asking for this and the test button appears without a reload.
state.me.user = updated;
status.textContent = notifyStatus(updated.ntfy_topic);
summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
test.hidden = !updated.ntfy_topic;
const created = await api.addChannel(user.id, type.value, address.value.trim());
address.value = '';
details.open = false;
toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.');
onAdded(created);
toast('Channel added. Send a test to make sure it arrives.');
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
@@ -140,10 +199,8 @@ function notifyForm(user) {
}
});
return h('div', { class: 'card pw-form' },
status,
h('div', { class: 'row-actions' }, test),
details);
const details = h('details', { class: 'account-fold' }, h('summary', { text: 'Add a channel' }), form);
return details;
}
// With password login switched off a password opens nothing, so somebody who
+8 -1
View File
@@ -23,6 +23,13 @@ let data = null; // { user, teams, allTeams }
let error = null;
let busy = false;
// Which kinds of channel the person has, never where they go: an address can be
// a secret, and the server does not send it to anybody but its owner.
function channelSummary(channels) {
if (!channels || !channels.length) return 'None of their own';
return channels.map((c) => (c.enabled ? c.type : `${c.type} (off)`)).join(', ');
}
export function show(route) {
const next = route && route.user != null ? route.user : null;
if (next !== userID) {
@@ -104,7 +111,7 @@ function identityCard() {
h('dl', { class: 'user-facts' },
fact('Email', u.email),
fact('Joined', when(u.created_at)),
fact('Notifications', u.ntfy_topic ? `ntfy: ${u.ntfy_topic}` : 'None of their own'),
fact('Notifications', channelSummary(u.channels)),
u.disabled_at && fact('Disabled', when(u.disabled_at)),
),
// Both of these refuse your own account, and the last administrator's. An
+16 -5
View File
@@ -76,10 +76,22 @@ export const users = () => call('GET', '/users');
// about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
// Where this user's pages go. An empty topic clears it, which the server
// treats as "no topic of their own" rather than an error.
export const setNotifyTarget = (id, ntfyTopic) =>
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
// Where this user's pages go: a list of channels, each a provider type and an
// address that provider understands. Only the owner is sent the addresses.
export const channels = (userID) => call('GET', `/users/${userID}/channels`);
export const addChannel = (userID, type, address) =>
call('POST', `/users/${userID}/channels`, { body: { type, address } });
// patch is { enabled } and/or { address }.
export const updateChannel = (userID, channelID, patch) =>
call('PATCH', `/users/${userID}/channels/${channelID}`, { body: patch });
export const removeChannel = (userID, channelID) =>
call('DELETE', `/users/${userID}/channels/${channelID}`);
export const testChannel = (userID, channelID) =>
call('POST', `/users/${userID}/channels/${channelID}/test`);
// Which kinds of channel this server can deliver: [{ type, label, address_label,
// address_hint }]. Empty when the server has nothing configured.
export const notifyProviders = () => call('GET', '/notify/providers');
// incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
@@ -119,7 +131,6 @@ export const createInvite = (id, role, maxUses) =>
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
export const testNotification = () => call('POST', '/me/notify/test');
export const dismissOnboarding = (dismissed) =>
call('PUT', '/me/onboarding', { body: { dismissed } });
+9 -5
View File
@@ -265,14 +265,18 @@ function eventText(ev, named = false) {
case 'note': return [strong(person), ' added a note'];
case 'resolution_note': return [strong(person), ' noted what fixed it'];
case 'notified': {
const to = person ? strong(person) : 'the fallback topic';
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
if (ev.detail === 'resolved') return ['Resolution sent to ', to];
const to = person ? strong(person) : 'the fallback';
// The detail is "<kind> via <channel type>"; a person with two channels
// has two of these per page, so the channel is what tells them apart.
const [kind, via] = (ev.detail || '').split(' via ');
const on = via ? ` on ${via}` : '';
if (kind === 'reminder') return ['Reminder sent to ', to, on];
if (kind === 'resolved') return ['Resolution sent to ', to, on];
// 'escalated' is a second, later page — the next level firing, not the
// same page landing twice — so it reads as a bug unless told apart
// from the initial 'triggered' page below.
if (ev.detail === 'escalated') return ['Escalation paged ', to];
return ['Paged ', to];
if (kind === 'escalated') return ['Escalation paged ', to, on];
return ['Paged ', to, on];
}
case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : ''];
case 'deadman_silent': return ['Heartbeat went silent', ev.detail ? ` (${ev.detail})` : ''];
+12 -8
View File
@@ -36,10 +36,10 @@ export async function load() {
]);
steps = [
{
id: 'topic',
id: 'channel',
title: 'Set where your pages go',
text: 'An ntfy topic on your account. Without one, incidents assigned to you page the team’s fallback topic instead of your phone.',
done: Boolean(state.me?.user?.ntfy_topic),
text: 'A notification channel on your account. Without one, incidents assigned to you page the team’s fallback instead of you.',
done: Boolean(firstChannel()),
action: { label: 'Account', go: '/more' },
},
{
@@ -115,10 +115,10 @@ function stepRow(step) {
class: 'btn-sm', type: 'button', text: step.action.label,
onclick: () => navigate(step.action.go),
}),
// The topic step is the only one this page can finish by itself, and
// The channel step is the only one this page can finish by itself, and
// the only proof that matters is a phone buzzing.
step.id === 'topic' && state.me?.user?.ntfy_topic && h('button', {
class: 'btn-sm', type: 'button', text: 'Send a test push',
step.id === 'channel' && firstChannel() && h('button', {
class: 'btn-sm', type: 'button', text: 'Send a test',
disabled: busy,
onclick: sendTest,
}),
@@ -126,11 +126,15 @@ function stepRow(step) {
);
}
// The first channel that is switched on: the one a page would reach first.
const firstChannel = () => (state.me?.user?.channels || []).find((c) => c.enabled);
async function sendTest() {
busy = true;
try {
await api.testNotification();
testResult = { ok: true, text: 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.' };
const channel = firstChannel();
await api.testChannel(state.me.user.id, channel.id);
testResult = { ok: true, text: 'Sent. If nothing arrives, the address is wrong or the server cannot reach the provider.' };
} catch (err) {
testResult = { ok: false, text: err.message };
} finally {
+38 -16
View File
@@ -213,7 +213,7 @@ function overview() {
owners === 1 ? '1 owner, full access.' : `${owners} owners, full access.`),
menuCard('/team/escalation', 'Escalation', levels || null,
levels
? `${levels} escalation level${levels === 1 ? '' : 's'}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
? `${levels} escalation level${levels === 1 ? '' : 's'}${data.escalation.fallback?.address ? ', then a fallback.' : '.'}`
: 'No ladder — nobody but the first person is woken.'),
menuCard('/team/sources', 'Alert sources', keys || null,
keys
@@ -630,16 +630,16 @@ function escalationCard() {
if (levels.length) {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` },
icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))),
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback?.address ? '' : ' warn'}` },
icon(esc.fallback?.address ? 'bell' : 'flag', 'icon'))),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' },
h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })),
esc.fallback_topic
? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.')
h('strong', { text: esc.fallback?.address ? 'Fallback' : 'End of the chain' })),
esc.fallback?.address
? h('div', { class: 'step-pages' }, `${esc.fallback.type}: `, h('code', { text: esc.fallback.address }), ' is paged once.')
: h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'),
h('div', {},
h('strong', { text: 'No fallback topic' }),
h('strong', { text: 'No fallback' }),
h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }),
isOwner() && h('button', {
class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }),
@@ -682,11 +682,17 @@ function escalationCard() {
// it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided. The draft lives in the sheet, so a poll of
// the page underneath cannot throw away half an edit.
function openLadderEditor({ focusFallback = false } = {}) {
async function openLadderEditor({ focusFallback = false } = {}) {
const esc = data.escalation || {};
// Which kinds of fallback this server can deliver. Failing to ask leaves the
// fallback uneditable rather than the whole ladder.
const providers = await api.notifyProviders().catch(() => []);
const draft = {
repeat_count: esc.repeat_count || 0,
fallback_topic: esc.fallback_topic || '',
fallback: {
type: esc.fallback?.type || providers[0]?.type || '',
address: esc.fallback?.address || '',
},
levels: (esc.levels || []).map((l) => ({
timeout_seconds: l.timeout_seconds,
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
@@ -734,12 +740,23 @@ function openLadderEditor({ focusFallback = false } = {}) {
value: String(draft.repeat_count),
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
});
const fallback = fallbackInput = h('input', {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; },
});
parts.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
parts.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
if (providers.length) {
const fallbackKind = h('select', {
'aria-label': 'Kind of fallback',
onchange: (e) => { draft.fallback.type = e.target.value; },
}, ...providers.map((p) => h('option', {
value: p.type, text: p.label, selected: p.type === draft.fallback.type,
})));
const fallback = fallbackInput = h('input', {
type: 'text', value: draft.fallback.address, placeholder: 'terdut-oncall-all',
autocomplete: 'off', autocapitalize: 'none', spellcheck: false,
oninput: (e) => { draft.fallback.address = e.target.value; },
});
parts.push(h('label', {}, 'Then page ',
providers.length > 1 ? fallbackKind : providers[0].label, ' ', fallback, ' once'));
parts.push(h('p', { class: 'muted small', text: 'Leave it empty for no fallback. Whoever knows this address is paged in place of a person.' }));
}
clear(body, ...parts);
};
paint();
@@ -747,7 +764,12 @@ function openLadderEditor({ focusFallback = false } = {}) {
const save = h('button', { class: 'btn btn-primary', type: 'button', text: 'Save ladder' });
save.addEventListener('click', async () => {
try {
await api.setEscalation(teamID, draft);
// An address with no type, or a type with no address, is refused; an empty
// address means "no fallback", so send it as such.
const fallback = draft.fallback.address.trim()
? { type: draft.fallback.type, address: draft.fallback.address.trim() }
: { type: '', address: '' };
await api.setEscalation(teamID, { ...draft, fallback });
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
@@ -1164,7 +1186,7 @@ function openSwitchDetail(sw) {
const MEMBER_STATUS = {
oncall: { label: 'On call', hint: 'The rota has them today.' },
reachable: { label: 'Reachable', hint: 'Has an ntfy topic, so a page would reach them.' },
reachable: { label: 'Reachable', hint: 'Has a notification channel, so a page would reach them.' },
unpageable: { label: 'Can’t be paged', hint: 'A page to them would go nowhere.' },
};