Show the escalation ladder as a list, with who it would page and where it is
Team -> Escalation was the draft form on the page, which showed the ladder only as inputs. It is now a table in the style of Switches and Sources: a row per level with a status badge, who it pages, the wait before the next level, and the open incidents currently waiting on it. Below it, the repeat count, the fallback topic and when the ladder last escalated (linking the incident). The editor moved into an "Edit ladder" sheet, so a poll of the page underneath can no longer throw away half an edit, and the page-level draft state went with it. Targets are resolved to who they mean today, and the badge says what would actually happen: Ready, Escalating (an unanswered incident has climbed to level 2 or higher), or Pages nobody. The last is the one worth seeing before an incident finds it: an empty rota, a person with no ntfy topic or a disabled account each make a rung a silence with a number on it, and the target says which. The rules are pageLevel's own, so the page cannot promise a page the notifier would skip. "Last escalated" comes from the escalated timeline events that already exist, so there is no migration. Acknowledging or resolving takes an incident off the ladder, so Escalating clears then while the history stays. API: GET /escalation gains status and waiting per level, username, reachable and problem per target, and last_escalated_at and last_escalated_incident_id. Output only and additive; PUT is unchanged and terdut-tui needs nothing.
This commit is contained in:
+143
-69
@@ -52,12 +52,10 @@ let freshKey = null; // an integration key, shown once, until the view is left
|
||||
export function show(route) {
|
||||
const next = route?.tab ?? null;
|
||||
// A different sub-section wants different data, so the old answer goes
|
||||
// rather than being shown under the new heading until the fetch lands. The
|
||||
// ladder draft goes with it: it is an edit of the page being left.
|
||||
// rather than being shown under the new heading until the fetch lands.
|
||||
if (next !== tab) {
|
||||
tab = next;
|
||||
data = null;
|
||||
draft = null;
|
||||
}
|
||||
if (!data) clear(view(), subnav(), spinner());
|
||||
refresh();
|
||||
@@ -185,7 +183,6 @@ function teamPicker() {
|
||||
teamID = Number(select.value);
|
||||
data = null;
|
||||
freshKey = null;
|
||||
draft = null;
|
||||
refresh();
|
||||
});
|
||||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
||||
@@ -457,57 +454,124 @@ function memberSelect(selected) {
|
||||
|
||||
// --- escalation ------------------------------------------------------------
|
||||
|
||||
// The ladder is edited as a whole and sent as a whole, because the API replaces
|
||||
// it wholesale: the levels are an order, and patching one rung would leave the
|
||||
// numbering of the others undecided.
|
||||
let draft = null;
|
||||
const LEVEL_STATUS = {
|
||||
ready: { label: 'Ready', hint: 'Somebody here can be woken.' },
|
||||
escalating: { label: 'Escalating', hint: 'An unanswered incident has climbed to this level.' },
|
||||
unreachable: { label: 'Pages nobody', hint: 'Nobody on this level can be woken right now.' },
|
||||
};
|
||||
|
||||
const levelBadge = (status) => statusBadge(LEVEL_STATUS, status, 'ready');
|
||||
|
||||
// One target as the list shows it: who it means today, and why it would not
|
||||
// wake them if it would not.
|
||||
function targetLine(t) {
|
||||
const label = t.kind === 'oncall'
|
||||
? `On call${t.username ? ` · ${t.username}` : ''}`
|
||||
: (t.username || 'Unknown person');
|
||||
return h('div', { class: 'target-line' },
|
||||
h('span', { text: label }),
|
||||
t.problem && h('span', { class: 'target-problem', text: t.problem }));
|
||||
}
|
||||
|
||||
function escalationCard() {
|
||||
const esc = data.escalation;
|
||||
if (!draft) {
|
||||
draft = {
|
||||
repeat_count: esc.repeat_count || 0,
|
||||
fallback_topic: esc.fallback_topic || '',
|
||||
levels: (esc.levels || []).map((l) => ({
|
||||
timeout_seconds: l.timeout_seconds,
|
||||
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
|
||||
const esc = data.escalation || {};
|
||||
const levels = esc.levels || [];
|
||||
|
||||
const rows = levels.map((l) => h('tr', {},
|
||||
h('td', {}, h('strong', { text: `Level ${l.position}` })),
|
||||
h('td', {}, levelBadge(l.status)),
|
||||
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
|
||||
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
|
||||
h('td', { class: 'small' }, l.waiting?.length
|
||||
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
|
||||
: h('span', { class: 'muted', text: '—' })),
|
||||
));
|
||||
|
||||
const facts = [];
|
||||
if (levels.length) {
|
||||
const n = esc.repeat_count || 0;
|
||||
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
|
||||
facts.push(esc.fallback_topic
|
||||
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
|
||||
: 'No fallback topic: after the last level the chain just ends.');
|
||||
facts.push(esc.last_escalated_at
|
||||
? ['Last escalated ',
|
||||
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
|
||||
' on ', h('a', { href: `/incidents/${esc.last_escalated_incident_id}`, text: `#${esc.last_escalated_incident_id}` }), '.']
|
||||
: 'Nothing has needed to escalate yet.');
|
||||
}
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Escalation' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', onclick: openLadderEditor,
|
||||
text: levels.length ? 'Edit ladder' : 'Set up ladder',
|
||||
})),
|
||||
};
|
||||
}
|
||||
h('p', { class: 'muted small' },
|
||||
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||||
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||||
levels.length
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
|
||||
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
|
||||
h('tbody', {}, rows)))
|
||||
: h('p', { class: 'muted' },
|
||||
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||||
'reminder interval and nobody else is woken.'),
|
||||
...facts.map((f) => h('p', { class: 'muted small' }, f)),
|
||||
);
|
||||
}
|
||||
|
||||
const body = [];
|
||||
if (!draft.levels.length) {
|
||||
body.push(h('p', { class: 'muted' },
|
||||
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||||
'reminder interval and nobody else is woken.'));
|
||||
}
|
||||
// The ladder is edited as a whole and sent as a whole, because the API replaces
|
||||
// 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() {
|
||||
const esc = data.escalation || {};
|
||||
const draft = {
|
||||
repeat_count: esc.repeat_count || 0,
|
||||
fallback_topic: esc.fallback_topic || '',
|
||||
levels: (esc.levels || []).map((l) => ({
|
||||
timeout_seconds: l.timeout_seconds,
|
||||
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
|
||||
})),
|
||||
};
|
||||
|
||||
draft.levels.forEach((level, i) => {
|
||||
body.push(h('div', { class: 'ladder-level' },
|
||||
h('div', { class: 'ladder-head' },
|
||||
h('strong', { text: `Level ${i + 1}` }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
onclick: () => { draft.levels.splice(i, 1); render(); },
|
||||
})),
|
||||
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
|
||||
level.timeout_seconds = secs;
|
||||
}), ' before the next level'),
|
||||
h('div', { class: 'ladder-targets' },
|
||||
...level.targets.map((t, ti) => targetRow(level, t, ti)),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn-sm', type: 'button', text: '+ target',
|
||||
onclick: () => { level.targets.push({ kind: 'oncall' }); render(); },
|
||||
})),
|
||||
));
|
||||
});
|
||||
const body = h('div', { class: 'ladder-editor' });
|
||||
const problem = h('p', { class: 'load-error', hidden: true });
|
||||
|
||||
if (isOwner()) {
|
||||
body.push(h('button', {
|
||||
const paint = () => {
|
||||
const parts = [];
|
||||
if (!draft.levels.length) {
|
||||
parts.push(h('p', { class: 'muted small' }, 'No levels yet. Add the first one.'));
|
||||
}
|
||||
draft.levels.forEach((level, i) => {
|
||||
parts.push(h('div', { class: 'ladder-level' },
|
||||
h('div', { class: 'ladder-head' },
|
||||
h('strong', { text: `Level ${i + 1}` }),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
onclick: () => { draft.levels.splice(i, 1); paint(); },
|
||||
})),
|
||||
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
|
||||
level.timeout_seconds = secs;
|
||||
}), ' before the next level'),
|
||||
h('div', { class: 'ladder-targets' },
|
||||
...level.targets.map((t, ti) => targetRow(level, t, ti, paint)),
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: '+ target',
|
||||
onclick: () => { level.targets.push({ kind: 'oncall' }); paint(); },
|
||||
})),
|
||||
));
|
||||
});
|
||||
parts.push(h('button', {
|
||||
class: 'btn-sm', type: 'button', text: '+ level',
|
||||
onclick: () => {
|
||||
draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] });
|
||||
render();
|
||||
paint();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -520,31 +584,43 @@ function escalationCard() {
|
||||
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
|
||||
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
||||
});
|
||||
body.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
|
||||
body.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
|
||||
body.push(h('button', {
|
||||
class: 'btn', type: 'button', text: 'Save ladder',
|
||||
onclick: () => act(() => api.setEscalation(teamID, draft), { resetDraft: true }),
|
||||
}));
|
||||
}
|
||||
parts.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
|
||||
parts.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
|
||||
clear(body, ...parts);
|
||||
};
|
||||
paint();
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Escalation' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||||
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||||
...body,
|
||||
);
|
||||
const save = h('button', { class: 'btn btn-primary', type: 'button', text: 'Save ladder' });
|
||||
save.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.setEscalation(teamID, draft);
|
||||
} catch (err) {
|
||||
problem.textContent = err.message;
|
||||
problem.hidden = false;
|
||||
return;
|
||||
}
|
||||
closeSheet(true);
|
||||
refresh();
|
||||
});
|
||||
|
||||
openSheet(() => [
|
||||
h('h2', { class: 'sheet-title', text: 'Edit ladder' }),
|
||||
body,
|
||||
problem,
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||||
save),
|
||||
]);
|
||||
}
|
||||
|
||||
function targetRow(level, target, index) {
|
||||
function targetRow(level, target, index, repaint) {
|
||||
const kind = h('select', {},
|
||||
h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }),
|
||||
h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' }));
|
||||
kind.addEventListener('change', () => {
|
||||
target.kind = kind.value;
|
||||
target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined;
|
||||
render();
|
||||
repaint();
|
||||
});
|
||||
|
||||
const who = target.kind === 'user'
|
||||
@@ -555,10 +631,10 @@ function targetRow(level, target, index) {
|
||||
}
|
||||
|
||||
return h('div', { class: 'target-row' }, kind, who,
|
||||
isOwner() && h('button', {
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: '×',
|
||||
title: 'Remove this target',
|
||||
onclick: () => { level.targets.splice(index, 1); render(); },
|
||||
onclick: () => { level.targets.splice(index, 1); repaint(); },
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -577,7 +653,7 @@ function integrationsCard() {
|
||||
const rows = (data.integrations || []).map((i) =>
|
||||
h('tr', {},
|
||||
h('td', {}, sourceBadge(i.status)),
|
||||
h('td', {},
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: i.name }),
|
||||
h('div', { class: 'muted small', text: i.kind })),
|
||||
// When the key last posted, and when an alert last arrived on it. They
|
||||
@@ -741,7 +817,7 @@ const triggeredCell = (iso, incidentID) => {
|
||||
function switchRows(sw) {
|
||||
const main = h('tr', {},
|
||||
h('td', {}, switchBadge(sw.status)),
|
||||
h('td', {},
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: sw.name }),
|
||||
sw.name !== sw.matcher && h('div', { class: 'muted small' }, h('code', { text: sw.matcher }))),
|
||||
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
|
||||
@@ -896,14 +972,12 @@ function membersCard() {
|
||||
// act runs a write and reloads. Errors are shown rather than thrown away: a
|
||||
// 409 from the last-owner guard or the schedule's conflict rule is the server
|
||||
// explaining itself, and the reader needs to see it.
|
||||
async function act(fn, { resetDraft = false } = {}) {
|
||||
async function act(fn) {
|
||||
try {
|
||||
await fn();
|
||||
error = null;
|
||||
if (resetDraft) draft = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
if (!resetDraft) draft = null;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user