dc3879eca6
Whoever is on call gets paged on a phone, and until now the only ways to
act on a page were the notification's Acknowledge button or a terminal.
Tapping the notification itself opened /api/incidents/{id}, which a
browser can only answer with a 401 in JSON. The server now serves a web
UI at / covering the incident queue, each incident's alerts and timeline
with every action on it, who is on call, the alert feed, and changing
your own password. The notification link now points at /incidents/{id}
in that UI.
It is embedded in the binary and has no build step: plain HTML, CSS and
ES modules under internal/web/static, served with an ETag per file and a
CSP that allows nothing from any other origin. That is how rd-web is
built. It avoids adding a node toolchain to the Dockerfile and the
pipeline for a page this size, and it keeps the page on the same origin
as the API, so no CORS is needed and nothing else has to be deployed.
Paths without a file extension fall back to index.html, so a deep link
survives a reload. An unknown path under /api/ still gets a JSON 404
rather than the page.
Signing in uses a username and password, because pasting a 64-character
API key into a phone at 3am is not a sign-in flow. Users have no
password until one is set through PUT /api/users/{id}/password, or
optionally at bootstrap. A user without a password is exactly where they
were before this commit and can only use API keys. A login sets an
HttpOnly, SameSite=Lax session cookie. It lasts 30 days and slides
forward while in use, so an on-call phone does not sign itself out.
Only the token's hash is stored, as for API keys.
The cookie needs a CSRF guard where a bearer header does not, because
browsers attach cookies to requests other sites make. So cookie-
authenticated requests go through Go 1.25's http.CrossOriginProtection,
and bearer requests do not. A request carrying an Authorization header
is judged on that header alone and never falls back to the cookie.
Changing a password ends every other session of that user. Changing
your own requires the current password, so a phone left signed in
cannot be used to take the account over.
Failed logins are counted per username and per client address. Ten
failures for one username in 15 minutes refuse that username for the
rest of the window, even with the right password. That makes locking
somebody out possible for anyone who knows their username. It was
accepted because the alternative is unlimited guessing, and during a
lockout the notification's Acknowledge button and API keys keep
working. The address limit reads the first X-Forwarded-For hop, since
behind the gateway RemoteAddr is Envoy. It is looser, because a whole
office behind one NAT shares it.
The Secure flag follows TERDUT_PUBLIC_URL, since TLS terminates at the
gateway and the server itself only ever sees plain HTTP. The chart
already defaults that variable to https://<hostname>.
Schedule editing, statistics and user management stay in terdut-tui for
now. The API they use is unchanged, and bearer authentication behaves
exactly as before.
448 lines
16 KiB
JavaScript
448 lines
16 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'));
|
|
}
|
|
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.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;
|
|
}
|
|
}
|