d728af53b1
Closes #17. Everything a team owner configures was API-only: escalation, integrations, dead man's switches, membership, and the rota -- which the on-call view still described as the TUI's job, and the TUI has been broken against this server since teams landed. Setting up the feature this whole line of work exists for meant using curl. A Team tab now holds all of it, one team at a time, with a picker for somebody in more than one. An owner edits; a member sees the same page without the controls, because the server refuses their writes anyway -- hiding a button is a courtesy to the reader, not the thing enforcing anything. The escalation editor holds a draft and sends the whole ladder, because the API replaces it wholesale: the levels are an order, and patching one rung leaves the numbering of the others undecided. Adding a level defaults to five minutes and the rota, which is the shape almost every ladder starts as. An integration key is returned exactly once, so creating one opens a panel that says so, shows the URL large with a copy button, and renders the Alertmanager receiver snippet with the URL already in it -- the next thing anybody does with that key is paste it into a config. The panel stays until it is dismissed rather than disappearing on the next re-render. The incident view gains where an incident is on the ladder and when the next page is due, which is the question somebody looking at an unacknowledged incident actually has. The API carries it: the incident payload now includes escalation_level and escalation_due_at, the latter computed in the incident SELECT by joining the level's timeout, so a list costs no extra queries. Verified against a live server by making every call the page makes, including the writes: the six reads the Team tab issues, a two-level ladder saved and read back, an integration created and its key returned once, three days of rota assigned, switches set, and an incident showing level 1 with a due time five minutes out. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
461 lines
17 KiB
JavaScript
461 lines
17 KiB
JavaScript
// Incident detail: facts, member alerts, the timeline with notes, and the
|
|
// action bar that carries everything a responder does to an incident.
|
|
|
|
import * as api from './api.js';
|
|
import * as poll from './poll.js';
|
|
import {
|
|
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
|
} from './ui.js';
|
|
import {
|
|
ago, when, until, isFuture, severityClass, STATUS_LABEL,
|
|
} from './format.js';
|
|
import { myID, users } from './state.js';
|
|
import { back } from './app.js';
|
|
|
|
const pane = () => document.getElementById('detail');
|
|
|
|
let currentID = null;
|
|
let inc = null;
|
|
let events = [];
|
|
let error = null;
|
|
let busy = false;
|
|
|
|
export function show(id) {
|
|
if (id === currentID) return;
|
|
currentID = id;
|
|
inc = null;
|
|
events = [];
|
|
error = null;
|
|
if (id == null) {
|
|
renderPlaceholder();
|
|
return;
|
|
}
|
|
render();
|
|
refresh();
|
|
}
|
|
|
|
export async function refresh() {
|
|
const id = currentID;
|
|
if (id == null) return;
|
|
try {
|
|
const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]);
|
|
if (id !== currentID) return;
|
|
inc = i;
|
|
events = t;
|
|
error = null;
|
|
} catch (err) {
|
|
if (id !== currentID) return;
|
|
error = err.status === 404 ? 'This incident does not exist.' : err.message;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function renderPlaceholder() {
|
|
clear(pane(), h('div', { class: 'detail-placeholder' },
|
|
h('div', {}, icon('flag', 'icon'), h('p', { text: 'Select an incident to see its alerts and timeline.' }))));
|
|
}
|
|
|
|
function render() {
|
|
const head = h('div', { class: 'detail-head' },
|
|
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
|
|
icon('back')),
|
|
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
|
|
);
|
|
|
|
if (!inc) {
|
|
clear(pane(), h('div', { class: 'detail' }, head,
|
|
error ? h('div', { class: 'load-error', text: error }) : spinner()));
|
|
return;
|
|
}
|
|
|
|
// Keep the scroll position across the periodic re-render.
|
|
const scroller = document.querySelector('.pane-detail');
|
|
const top = scroller ? scroller.scrollTop : 0;
|
|
|
|
clear(pane(),
|
|
h('article', { class: 'detail' },
|
|
head,
|
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
|
h('h1', { class: 'detail-title', text: inc.title }),
|
|
h('div', { class: 'detail-badges' }, statusBadges()),
|
|
facts(),
|
|
groupLabels(),
|
|
alertsSection(),
|
|
timelineSection(),
|
|
),
|
|
actionBar(),
|
|
);
|
|
if (scroller) scroller.scrollTop = top;
|
|
}
|
|
|
|
function statusBadges() {
|
|
const out = [];
|
|
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`));
|
|
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
|
|
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
|
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
|
}
|
|
// Where it is on the ladder, while it is still climbing. The queue shows
|
|
// what happened; this says what happens next, which is the question somebody
|
|
// looking at an unacknowledged incident actually has.
|
|
if (inc.escalation_level > 0) {
|
|
const left = inc.escalation_due_at && isFuture(inc.escalation_due_at)
|
|
? ` · next in ${until(inc.escalation_due_at)}`
|
|
: ' · next page due';
|
|
out.push(badge(`Escalating · level ${inc.escalation_level}${left}`, 'st-triggered'));
|
|
}
|
|
if (inc.archived_at) out.push(badge('Archived', 'plain'));
|
|
return out;
|
|
}
|
|
|
|
function who(id, name) {
|
|
if (id != null && id === myID()) return 'you';
|
|
return name || 'someone';
|
|
}
|
|
|
|
function facts() {
|
|
const rows = [];
|
|
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
|
|
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
|
|
if (inc.acknowledged_at) {
|
|
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
|
|
}
|
|
add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned');
|
|
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
|
add('Snoozed until', when(inc.snoozed_until));
|
|
}
|
|
if (inc.escalation_level > 0 && inc.escalation_due_at) {
|
|
add('Escalates next', when(inc.escalation_due_at),
|
|
h('span', { class: 'sub', text: ` · level ${inc.escalation_level}` }));
|
|
}
|
|
if (inc.resolved_at) {
|
|
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
|
|
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
|
|
}
|
|
if (inc.archived_at) add('Archived', when(inc.archived_at));
|
|
return h('div', { class: 'card' }, h('dl', { class: 'facts' }, rows));
|
|
}
|
|
|
|
function groupLabels() {
|
|
const entries = Object.entries(inc.group_labels || {});
|
|
if (!entries.length) return null;
|
|
return h('section', { class: 'section' },
|
|
h('h2', { class: 'section-title', text: 'Grouped by' }),
|
|
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))),
|
|
);
|
|
}
|
|
|
|
function alertsSection() {
|
|
const list = inc.alerts || [];
|
|
const firing = list.filter((a) => a.status === 'firing').length;
|
|
return h('section', { class: 'section' },
|
|
h('h2', { class: 'section-title' },
|
|
h('span', { text: `Alerts (${list.length})` }),
|
|
firing ? h('span', { text: `${firing} firing` }) : null),
|
|
list.length
|
|
? h('div', { class: 'card' }, list.map(alertItem))
|
|
: h('div', { class: 'card card-pad', text: 'No alerts attached.' }),
|
|
);
|
|
}
|
|
|
|
function alertItem(a) {
|
|
const summary = (a.annotations && (a.annotations.summary || a.annotations.description)) || '';
|
|
const labels = Object.entries(a.labels || {});
|
|
return h('div', { class: 'alert-item' },
|
|
h('div', { class: 'alert-item-head' },
|
|
h('span', { class: 'alert-item-name', text: a.name }),
|
|
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`)),
|
|
summary && h('div', { class: 'alert-item-summary', text: summary }),
|
|
h('div', { class: 'alert-item-foot' },
|
|
h('span', { text: `Started ${ago(a.starts_at)}` }),
|
|
h('span', { text: `Last seen ${ago(a.received_at)}` }),
|
|
a.generator_url && h('a', { href: a.generator_url, target: '_blank', rel: 'noopener noreferrer' }, 'Source ↗'),
|
|
),
|
|
labels.length > 0 && h('details', {},
|
|
h('summary', { text: `${labels.length} labels` }),
|
|
h('div', { class: 'labels-wrap' }, labels.map(([k, v]) => labelChip(k, v)))),
|
|
);
|
|
}
|
|
|
|
// ---------- timeline ----------
|
|
|
|
function eventText(ev) {
|
|
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
|
|
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
|
|
const alertName = () => {
|
|
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
|
|
return a ? a.name : 'an alert';
|
|
};
|
|
switch (ev.type) {
|
|
case 'triggered': return ['Incident triggered'];
|
|
case 'alert_added': return [`Alert added: ${alertName()}`];
|
|
case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
|
|
case 'acknowledged': return [strong(person), ' acknowledged'];
|
|
case 'unacknowledged': return [strong(person), ' cleared the acknowledgement'];
|
|
case 'assigned': return ['Assigned to ', strong(person)];
|
|
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
|
|
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
|
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
|
case 'note': return [strong(person), ' added a note'];
|
|
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];
|
|
return ['Paged ', to];
|
|
}
|
|
case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : ''];
|
|
case 'deadman_silent': return ['Heartbeat went silent', ev.detail ? ` (${ev.detail})` : ''];
|
|
default: return [ev.type, ev.detail ? `: ${ev.detail}` : ''];
|
|
}
|
|
}
|
|
|
|
function timelineSection() {
|
|
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
|
return h('section', { class: 'section' },
|
|
h('h2', { class: 'section-title' },
|
|
h('span', { text: 'Timeline' }),
|
|
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: addNote },
|
|
icon('note'), 'Add note')),
|
|
h('div', { class: 'card' },
|
|
sorted.length
|
|
? h('ol', { class: 'timeline' }, sorted.map(timelineItem))
|
|
: emptyState('No events yet', '')),
|
|
);
|
|
}
|
|
|
|
function timelineItem(ev) {
|
|
const mine = ev.type === 'note' && ev.user_id === myID();
|
|
return h('li', { class: `tl-item tl-${ev.type}` },
|
|
h('span', { class: 'tl-dot' }),
|
|
h('div', { class: 'tl-body' },
|
|
h('div', { class: 'tl-text' }, eventText(ev)),
|
|
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
|
|
ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }),
|
|
mine && h('div', { class: 'note-actions' },
|
|
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------- actions ----------
|
|
|
|
const isOpen = () => inc.status !== 'resolved';
|
|
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
|
|
|
function actionBar() {
|
|
let primary;
|
|
let secondary;
|
|
if (inc.status === 'triggered') {
|
|
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
|
} else if (inc.status === 'acknowledged') {
|
|
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
|
|
} else {
|
|
primary = inc.archived_at
|
|
? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive')
|
|
: h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive');
|
|
}
|
|
if (isOpen()) {
|
|
secondary = isSnoozed()
|
|
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
|
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
|
|
} else {
|
|
secondary = h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
|
|
}
|
|
const more = h('button', { class: 'btn btn-icon', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'));
|
|
const bar = h('div', { class: 'actionbar' }, primary, secondary, more);
|
|
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
|
|
return bar;
|
|
}
|
|
|
|
// run performs one action, then reloads the incident and the queue.
|
|
async function run(fn, done) {
|
|
if (busy) return;
|
|
busy = true;
|
|
render();
|
|
try {
|
|
await fn();
|
|
if (done) toast(done);
|
|
} catch (err) {
|
|
toast(err.message, 'error');
|
|
} finally {
|
|
busy = false;
|
|
await refresh();
|
|
poll.now();
|
|
}
|
|
}
|
|
|
|
function acknowledge() {
|
|
const id = inc.id;
|
|
return run(() => api.acknowledge(id), 'Acknowledged');
|
|
}
|
|
|
|
function unacknowledge() {
|
|
const id = inc.id;
|
|
return run(() => api.unacknowledge(id), 'Acknowledgement cleared');
|
|
}
|
|
|
|
async function resolve() {
|
|
const id = inc.id;
|
|
const ok = await confirm({
|
|
title: 'Resolve this incident?',
|
|
text: 'Resolving is final. If these alerts fire again they open a new incident, '
|
|
+ 'and if any are still firing this one stays closed regardless. '
|
|
+ 'Use snooze if you only need it out of the way.',
|
|
confirmLabel: 'Resolve',
|
|
danger: true,
|
|
});
|
|
if (ok) await run(() => api.resolve(id), 'Resolved');
|
|
}
|
|
|
|
function archive() {
|
|
const id = inc.id;
|
|
return run(() => api.archive(id), 'Archived');
|
|
}
|
|
|
|
function unarchive() {
|
|
const id = inc.id;
|
|
return run(() => api.unarchive(id), 'Unarchived');
|
|
}
|
|
|
|
function unsnooze() {
|
|
const id = inc.id;
|
|
return run(() => api.unsnooze(id), 'Snooze ended');
|
|
}
|
|
|
|
async function snooze() {
|
|
const id = inc.id;
|
|
const tomorrow9 = new Date();
|
|
tomorrow9.setDate(tomorrow9.getDate() + 1);
|
|
tomorrow9.setHours(9, 0, 0, 0);
|
|
|
|
const options = [
|
|
['30 minutes', { duration: '30m' }],
|
|
['1 hour', { duration: '1h' }],
|
|
['2 hours', { duration: '2h' }],
|
|
['4 hours', { duration: '4h' }],
|
|
['8 hours', { duration: '8h' }],
|
|
['Until 09:00 tomorrow', { until: tomorrow9.toISOString() }],
|
|
];
|
|
const spec = await openSheet(() => [
|
|
h('h2', { class: 'sheet-title', text: 'Snooze' }),
|
|
h('p', { class: 'sheet-text', text: 'Hide it from the queue for a while. It comes back on its own.' }),
|
|
h('ul', { class: 'menu' }, options.map(([label, value]) =>
|
|
h('li', {}, h('button', { class: 'menu-item', type: 'button', onclick: () => closeSheet(value) },
|
|
icon('clock'), label)))),
|
|
]);
|
|
if (spec) await run(() => api.snooze(id, spec), 'Snoozed');
|
|
}
|
|
|
|
async function assign() {
|
|
const id = inc.id;
|
|
let list;
|
|
let onCall;
|
|
try {
|
|
[list, onCall] = await Promise.all([users(), api.onCallNow()]);
|
|
} catch (err) {
|
|
toast(err.message, 'error');
|
|
return;
|
|
}
|
|
const me = myID();
|
|
const sorted = [...list].sort((a, b) => (b.id === me) - (a.id === me) || a.username.localeCompare(b.username));
|
|
const userID = await openSheet(() => [
|
|
h('h2', { class: 'sheet-title', text: 'Assign to' }),
|
|
h('ul', { class: 'menu', role: 'menu' }, sorted.map((u) =>
|
|
h('li', {}, h('button', {
|
|
class: 'menu-item',
|
|
type: 'button',
|
|
role: 'menuitemradio',
|
|
'aria-checked': String(u.id === inc.assigned_to_id),
|
|
onclick: () => closeSheet(u.id),
|
|
},
|
|
icon('user'),
|
|
u.id === me ? `${u.username} (you)` : u.username,
|
|
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
|
|
)))),
|
|
]);
|
|
if (userID != null) await run(() => api.assign(id, userID), 'Assigned');
|
|
}
|
|
|
|
async function addNote() {
|
|
const id = inc.id;
|
|
const content = await openSheet(() => {
|
|
const textarea = h('textarea', {
|
|
name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000',
|
|
});
|
|
const form = h('form', {
|
|
class: 'sheet-form',
|
|
onsubmit: (e) => {
|
|
e.preventDefault();
|
|
const v = textarea.value.trim();
|
|
if (v) closeSheet(v);
|
|
},
|
|
},
|
|
h('h2', { class: 'sheet-title', text: 'Add note' }),
|
|
textarea,
|
|
h('div', { class: 'sheet-actions' },
|
|
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
|
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })),
|
|
);
|
|
// Ctrl/Cmd+Enter saves, as in most note fields.
|
|
textarea.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit();
|
|
});
|
|
return form;
|
|
});
|
|
if (content) await run(() => api.addNote(id, content), 'Note added');
|
|
}
|
|
|
|
async function deleteNote(ev) {
|
|
const id = inc.id;
|
|
const ok = await confirm({ title: 'Delete this note?', text: ev.detail || '', confirmLabel: 'Delete', danger: true });
|
|
if (ok) await run(() => api.deleteNote(id, ev.id), 'Note deleted');
|
|
}
|
|
|
|
async function moreMenu() {
|
|
const item = (iconName, label, fn, cls = '') =>
|
|
h('li', {}, h('button', { class: `menu-item ${cls}`, type: 'button', onclick: () => closeSheet(fn) }, icon(iconName), label));
|
|
|
|
const items = [];
|
|
if (isOpen()) {
|
|
if (inc.status === 'triggered') items.push(item('check', 'Acknowledge', acknowledge));
|
|
else items.push(item('undo', 'Clear acknowledgement', unacknowledge));
|
|
items.push(item('user', 'Assign…', assign));
|
|
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
|
|
items.push(item('note', 'Add note…', addNote));
|
|
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
|
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
|
} else {
|
|
items.push(item('note', 'Add note…', addNote));
|
|
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
|
}
|
|
|
|
const fn = await openSheet(() => [
|
|
h('h2', { class: 'sheet-title', text: inc.title }),
|
|
h('ul', { class: 'menu' }, items),
|
|
]);
|
|
if (fn) await fn();
|
|
}
|
|
|
|
// key handles the detail's shortcuts. Returns true when it used the key.
|
|
export function key(e) {
|
|
if (!inc) {
|
|
if (e.key === 'Escape') {
|
|
back();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
switch (e.key) {
|
|
case 'Escape': back(); return true;
|
|
case 'a': if (inc.status === 'triggered') acknowledge(); return true;
|
|
case 'A': if (inc.status === 'acknowledged') unacknowledge(); return true;
|
|
case 'R': if (isOpen()) resolve(); return true;
|
|
case 's': if (isOpen()) assign(); return true;
|
|
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
|
|
case 'Z': if (isSnoozed()) unsnooze(); return true;
|
|
case 'c': addNote(); return true;
|
|
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
|
|
default: return false;
|
|
}
|
|
}
|