// 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; } }