91f03c21e8
Addresses the screenshot-review feedback in #26. No framework or build step added — all of this stays within the existing plain HTML/CSS/ vanilla-JS + go:embed architecture. - Nav: re-enable the bottom tab bar that was already built and switched off (Queue/On-call/Alerts/Team + a "More" sheet for Stats/Admin/Account), replacing the hamburger on phone width. - Queue: chip counts, a scroll fade on the filter row, a "Triggered Xh ago" + severity label per row, a chevron on the team switcher so it reads as a dropdown. - On-call: collapse repeated same-person days into shift bars (week view and "your shifts" both), show the week as a date range with the ISO week number as secondary text, split "Current shift" out from "Next shifts" with "ends in Nd", a pill badge + row highlight for "you". - Incident detail: fix the actual bug behind the duplicate "acknowledged" timeline entries (acknowledgeIncident's UPDATE had no guard on the incident's current status, so acknowledging an already-acknowledged incident silently re-logged the event — now idempotent, with regression tests on both the authenticated route and the ntfy ack-button route). Relabel escalation re-pages so they don't look like the same page landing twice. Copy the primary action up near the top. Label the "···" button. Group the timeline by phase (triggered/acknowledged/resolved). Add an "at a glance" summary row (duration/severity/responsible) and collapse the group labels by default. - Team overview: reword the vague copy ("One owner." etc.) into plain labels. - Empty states: fill in missing icons/one-liners across queue, alerts, stats and the incident timeline. - CSS: fix card padding bugs, verify link contrast already passes AA, introduce a --fs-* type-scale token set and migrate the few genuinely isolated cases onto it (left sizes tied to a fixed shape, a deliberately prominent display, or a non-negotiable constraint like the iOS-zoom-prevention input size as documented exceptions rather than guess at a render this change can't see). Verified with the full fmt/lint/test/helm-lint gate, plus a live instance against the test DB with seeded incidents and schedule data to trace the on-call grouping and timeline phase-splitting logic against real API responses. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
679 lines
27 KiB
JavaScript
679 lines
27 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, duration, 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 similarList = [];
|
|
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 {
|
|
// Similar incidents are a courtesy: an older server answers 404 and a
|
|
// failure here must not hide the incident itself.
|
|
const [i, t, sim] = await Promise.all([
|
|
api.incident(id), api.timeline(id), api.similar(id).catch(() => []),
|
|
]);
|
|
if (id !== currentID) return;
|
|
inc = i;
|
|
events = t;
|
|
similarList = sim;
|
|
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}` : '' }),
|
|
inc && h('button', { class: 'btn btn-ghost btn-icon copy', type: 'button', 'aria-label': 'Copy incident', title: 'Copy incident (y)', onclick: copyIncident },
|
|
icon('copy')),
|
|
);
|
|
|
|
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()),
|
|
quickActions(),
|
|
facts(),
|
|
groupLabels(),
|
|
alertsSection(),
|
|
similarSection(),
|
|
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));
|
|
|
|
// Duration, severity and who's on it, in one scannable row up top — the
|
|
// rest of this card has each of those too, but spread across rows that
|
|
// take reading top to bottom to piece together.
|
|
const elapsedTo = inc.resolved_at ? Date.parse(inc.resolved_at) : Date.now();
|
|
const responsible = inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to)
|
|
: inc.acknowledged_by_id != null ? who(inc.acknowledged_by_id, inc.acknowledged_by)
|
|
: 'Unassigned';
|
|
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
|
|
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
|
|
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
|
|
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
|
|
));
|
|
|
|
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;
|
|
// Collapsed by default, the same disclosure alertItem() below uses for an
|
|
// alert's own labels — this is background, not something to scan past.
|
|
return h('section', { class: 'section' },
|
|
h('details', {},
|
|
h('summary', { text: `Grouped by (${entries.length})` }),
|
|
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 ----------
|
|
|
|
// named spells users out instead of "you", for text that leaves this page.
|
|
function eventText(ev, named = false) {
|
|
const person = ev.user_id != null ? (named ? ev.username || 'someone' : 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'];
|
|
// Falls through to the generic `${ev.type}: ${ev.detail}` below otherwise
|
|
// — this just capitalises it and drops the redundant "escalated:" prefix
|
|
// from detail (already "level 2: alice, bob" or "escalation exhausted: …").
|
|
case 'escalated': return [`Escalated — ${ev.detail}`];
|
|
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];
|
|
// '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];
|
|
}
|
|
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}` : ''];
|
|
}
|
|
}
|
|
|
|
// Earlier incidents with the same signature that someone left notes on, the
|
|
// ones that recorded what fixed it first. Plain notes are on that incident's
|
|
// own page.
|
|
function similarSection() {
|
|
if (!similarList.length) return null;
|
|
return h('section', { class: 'section' },
|
|
h('h2', { class: 'section-title' }, h('span', { text: 'Seen before' })),
|
|
h('div', { class: 'card' },
|
|
h('ul', { class: 'similar' }, similarList.map((s) => h('li', { class: 'similar-item' },
|
|
h('a', { href: `/incidents/${s.id}`, text: `#${s.id} ${s.title}` }),
|
|
h('div', { class: 'sub', text: `${when(s.resolved_at)} · ${ago(s.resolved_at)}${s.note_count ? ` · ${s.note_count} note${s.note_count === 1 ? '' : 's'}` : ''}` }),
|
|
...s.resolution_notes.map((n) => h('div', { class: 'note note-fix', text: n.detail || '' })),
|
|
)))),
|
|
);
|
|
}
|
|
|
|
// Splits the already-sorted timeline on the status transitions that matter —
|
|
// first acknowledged, then resolved — so a long incident reads as "before
|
|
// anyone had it" / "while someone did" / "after it closed" instead of one
|
|
// undifferentiated list. A later re-acknowledge (after an unacknowledge)
|
|
// doesn't open a second "Acknowledged" phase; it's still the same spell of
|
|
// somebody owning it.
|
|
function timelinePhases(sorted) {
|
|
const phases = [{ label: 'Triggered', events: [] }];
|
|
let acked = false;
|
|
for (const ev of sorted) {
|
|
if (ev.type === 'acknowledged' && !acked) {
|
|
phases.push({ label: 'Acknowledged', events: [] });
|
|
acked = true;
|
|
} else if (ev.type === 'resolved') {
|
|
phases.push({ label: 'Resolved', events: [] });
|
|
}
|
|
phases[phases.length - 1].events.push(ev);
|
|
}
|
|
return phases.filter((p) => p.events.length);
|
|
}
|
|
|
|
function timelineSection() {
|
|
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
|
const phases = timelinePhases(sorted);
|
|
// A single phase (the common case: most incidents are acked once and
|
|
// resolved) names nothing extra — only a split timeline needs the
|
|
// headings to make sense of.
|
|
const named = phases.length > 1;
|
|
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
|
|
? phases.map((p) => h('div', { class: 'tl-phase' },
|
|
named && h('div', { class: 'tl-phase-title', text: p.label }),
|
|
h('ol', { class: 'timeline' }, p.events.map(timelineItem))))
|
|
: emptyState('No events yet', 'Nothing has happened on this incident yet.', 'clock')),
|
|
);
|
|
}
|
|
|
|
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
|
|
|
|
function timelineItem(ev) {
|
|
const mine = isNote(ev) && 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)}` }),
|
|
isNote(ev) && h('div', { class: ev.type === 'resolution_note' ? 'note note-fix' : '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')),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------- copy ----------
|
|
|
|
const fence = (rows) => ['```', ...rows, '```'];
|
|
const pairs = (obj) => Object.entries(obj || {}).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`);
|
|
|
|
// incidentMarkdown is everything on this page as text that reads well in a chat
|
|
// or an agent prompt. Times are ISO 8601, since "3 min ago" means nothing once
|
|
// it has been pasted somewhere else.
|
|
function incidentMarkdown() {
|
|
const out = [`# Incident #${inc.id}: ${inc.title}`, ''];
|
|
const add = (k, v) => { if (v != null && v !== '') out.push(`- ${k}: ${v}`); };
|
|
add('Status', inc.status);
|
|
add('Severity', inc.severity);
|
|
add('Team', inc.team_name);
|
|
add('Assigned to', inc.assigned_to_id != null ? inc.assigned_to || 'someone' : 'unassigned');
|
|
add('Triggered', inc.triggered_at);
|
|
if (inc.acknowledged_at) add('Acknowledged', `${inc.acknowledged_at} by ${inc.acknowledged_by || 'someone'}`);
|
|
if (isOpen() && isFuture(inc.snoozed_until)) add('Snoozed until', inc.snoozed_until);
|
|
if (inc.escalation_level > 0) add('Escalation level', inc.escalation_level);
|
|
if (inc.resolved_at) add('Resolved', `${inc.resolved_at} (${inc.resolution_source === 'manual' ? 'manually' : 'all alerts stopped firing'})`);
|
|
if (inc.archived_at) add('Archived', inc.archived_at);
|
|
const group = pairs(inc.group_labels);
|
|
if (group.length) out.push('- Grouped by:', ...group.map((g) => ` - ${g}`));
|
|
|
|
const alerts = inc.alerts || [];
|
|
out.push('', `## Alerts (${alerts.length})`);
|
|
for (const a of alerts) {
|
|
out.push('', `### ${a.name} (${a.status})`);
|
|
out.push(`- Started: ${a.starts_at}`);
|
|
if (a.status === 'resolved' && a.ends_at) out.push(`- Ended: ${a.ends_at}`);
|
|
if (a.generator_url) out.push(`- Source: ${a.generator_url}`);
|
|
const labels = pairs(a.labels);
|
|
if (labels.length) out.push('', 'Labels:', ...fence(labels));
|
|
const annotations = Object.entries(a.annotations || {}).sort(([x], [y]) => x.localeCompare(y));
|
|
if (annotations.length) out.push('', 'Annotations:', ...fence(annotations.map(([k, v]) => `${k}: ${v}`)));
|
|
}
|
|
|
|
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
|
if (sorted.length) {
|
|
out.push('', '## Timeline', '');
|
|
for (const ev of sorted) {
|
|
const text = eventText(ev, true).map((f) => (f instanceof Node ? f.textContent : f)).join('');
|
|
out.push(`- ${ev.created_at} ${text}`);
|
|
if (isNote(ev) && ev.detail) {
|
|
const label = ev.type === 'resolution_note' ? ' (what fixed it)' : '';
|
|
out.push(...(label ? [label] : []), ...ev.detail.split('\n').map((l) => ` > ${l}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (similarList.length) {
|
|
out.push('', '## Seen before', '', 'Earlier incidents with the same signature:');
|
|
for (const s of similarList) {
|
|
out.push(`- #${s.id} ${s.title} (resolved ${s.resolved_at})`);
|
|
for (const n of s.resolution_notes || []) {
|
|
out.push(' - What fixed it:', ...(n.detail || '').split('\n').map((l) => ` > ${l}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
out.push('', `_Copied from Terminal Duty at ${new Date().toISOString()}_`, '');
|
|
return out.join('\n');
|
|
}
|
|
|
|
// writeClipboard falls back to execCommand: the async API needs a secure
|
|
// context, and this server is often reached over plain HTTP.
|
|
async function writeClipboard(text) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
const ta = h('textarea', { readonly: true, 'aria-hidden': 'true', class: 'clip-buffer' });
|
|
ta.value = text;
|
|
document.body.append(ta);
|
|
ta.select();
|
|
try {
|
|
if (!document.execCommand('copy')) throw new Error('copy refused');
|
|
} finally {
|
|
ta.remove();
|
|
}
|
|
}
|
|
|
|
async function copyIncident() {
|
|
if (!inc) return;
|
|
try {
|
|
await writeClipboard(incidentMarkdown());
|
|
toast('Copied incident');
|
|
} catch {
|
|
toast('Could not copy', 'error');
|
|
}
|
|
}
|
|
|
|
// ---------- actions ----------
|
|
|
|
const isOpen = () => inc.status !== 'resolved';
|
|
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
|
|
|
// primaryAction and secondaryAction are factories, not shared nodes — a
|
|
// button can only live in one place, and quickActions() below needs its own
|
|
// copy of the primary one rather than the actionbar's.
|
|
function primaryAction() {
|
|
if (inc.status === 'triggered') {
|
|
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
|
}
|
|
if (inc.status === 'acknowledged') {
|
|
return h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
|
|
}
|
|
return 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');
|
|
}
|
|
|
|
function secondaryAction() {
|
|
if (isOpen()) {
|
|
return isSnoozed()
|
|
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
|
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
|
|
}
|
|
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
|
|
}
|
|
|
|
// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen
|
|
// right away, next to the status it responds to. The sticky actionbar below
|
|
// keeps carrying every action, primary included, for whenever the page has
|
|
// been scrolled past it.
|
|
function quickActions() {
|
|
const div = h('div', { class: 'detail-quick-actions' }, primaryAction());
|
|
if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true;
|
|
return div;
|
|
}
|
|
|
|
function actionBar() {
|
|
const more = h('button', { class: 'btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
|
|
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), 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 res = await openSheet(() => {
|
|
const textarea = h('textarea', {
|
|
name: 'resolution', autofocus: true, maxlength: '10000',
|
|
placeholder: 'What fixed it? Optional, shown on the next similar incident.',
|
|
});
|
|
const form = h('form', {
|
|
class: 'sheet-form',
|
|
onsubmit: (e) => {
|
|
e.preventDefault();
|
|
closeSheet({ resolution: textarea.value.trim() });
|
|
},
|
|
},
|
|
h('h2', { class: 'sheet-title', text: 'Resolve this incident?' }),
|
|
h('p', {
|
|
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.',
|
|
}),
|
|
textarea,
|
|
h('div', { class: 'sheet-actions' },
|
|
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
|
h('button', { class: 'btn btn-danger', type: 'submit', text: 'Resolve' })),
|
|
);
|
|
textarea.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit();
|
|
});
|
|
return form;
|
|
});
|
|
if (res) await run(() => api.resolve(id, res.resolution), '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 fix = h('input', { type: 'checkbox', name: 'fix' });
|
|
const form = h('form', {
|
|
class: 'sheet-form',
|
|
onsubmit: (e) => {
|
|
e.preventDefault();
|
|
const v = textarea.value.trim();
|
|
if (v) closeSheet({ content: v, pinned: fix.checked });
|
|
},
|
|
},
|
|
h('h2', { class: 'sheet-title', text: 'Add note' }),
|
|
textarea,
|
|
h('label', { class: 'check' }, fix, ' This is what fixed it (shown on similar incidents)'),
|
|
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.content, content.pinned), '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(item('copy', 'Copy incident', copyIncident));
|
|
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(item('copy', 'Copy incident', copyIncident));
|
|
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 'y': copyIncident(); return true;
|
|
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
|
|
default: return false;
|
|
}
|
|
}
|