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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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})` : ''];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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