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:
@@ -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.' },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user