f344bb707b
Each alert carries a generatorURL pointing at the Prometheus rule that fired it. The server now parses that link, queries the same Prometheus for the rule's expression and draws it under the alert, with the current value beside it. The browser never talks to Prometheus, so the CSP stays at connect-src 'self'. The URL comes out of a webhook payload, so whoever holds an integration key chooses it. Querying it is therefore opt-in and bounded by an allowlist, TERDUT_PROMETHEUS_ALLOWED_HOSTS (names, "*" as one label, a leading "**" as several, IPs, CIDRs). The check runs on the address the dialer is about to use, a name that resolves to loopback or link-local is refused even when it matches, and redirects and proxy variables are ignored. A worker takes a snapshot when an incident opens and another when it closes, so the graph outlives Prometheus' retention. It finds its work in the database rather than being triggered from the webhook, so ingestion is untouched and a restart loses nothing. While an incident is open the metrics endpoint also queries live, cached for 15 seconds. The rule expression keeps its threshold, so Prometheus returns data only while the alert condition holds; "no data" for the current value means the condition cleared. Stripping the threshold needs a PromQL parser and is left out. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
749 lines
31 KiB
JavaScript
749 lines
31 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, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
|
} from './ui.js';
|
|
import {
|
|
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
|
|
} from './format.js';
|
|
import { myID, users } from './state.js';
|
|
import { back } from './app.js';
|
|
import { alertMetrics } from './metricchart.js';
|
|
|
|
const pane = () => document.getElementById('detail');
|
|
|
|
let currentID = null;
|
|
let inc = null;
|
|
let events = [];
|
|
let similarList = [];
|
|
let metricsByAlert = {};
|
|
let error = null;
|
|
let busy = false;
|
|
|
|
export function show(id) {
|
|
if (id === currentID) return;
|
|
currentID = id;
|
|
inc = null;
|
|
events = [];
|
|
metricsByAlert = {};
|
|
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.
|
|
// Metrics are a courtesy too: an older server has no such endpoint, and a
|
|
// Prometheus that is slow or down must not hold up the incident.
|
|
const [i, t, sim, met] = await Promise.all([
|
|
api.incident(id), api.timeline(id), api.similar(id).catch(() => []), api.metrics(id).catch(() => null),
|
|
]);
|
|
if (id !== currentID) return;
|
|
inc = i;
|
|
events = t;
|
|
similarList = sim;
|
|
metricsByAlert = {};
|
|
if (met && met.enabled) for (const m of met.alerts) metricsByAlert[m.alert_id] = m;
|
|
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()),
|
|
facts(),
|
|
groupLabels(),
|
|
alertsSection(),
|
|
similarSection(),
|
|
timelineSection(),
|
|
),
|
|
actionBar(),
|
|
);
|
|
if (scroller) scroller.scrollTop = top;
|
|
}
|
|
|
|
function statusBadges() {
|
|
const out = [];
|
|
const origin = originOf(inc.group_labels);
|
|
if (origin) out.push(originChip(origin));
|
|
if (inc.severity) out.push(severityBadge(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';
|
|
}
|
|
|
|
// ackActorLabel renders whoever acknowledged inc, human or service account —
|
|
// the two are mutually exclusive (migration 015), and a service account is a
|
|
// credential, not "you" or "nobody", so it gets its own branch rather than
|
|
// going through who()'s id-vs-myID() check.
|
|
function ackActorLabel() {
|
|
if (inc.acknowledged_by_id != null) return who(inc.acknowledged_by_id, inc.acknowledged_by);
|
|
if (inc.acknowledged_by_service_account_id != null) return inc.acknowledged_by_service_account || 'a service account';
|
|
return null;
|
|
}
|
|
|
|
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)
|
|
: ackActorLabel() || '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 && severityBadge(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', `${ackActorLabel()} · ${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 }),
|
|
alertMetrics(metricsByAlert[a.id], a.starts_at, inc.resolved_at),
|
|
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 ----------
|
|
|
|
// actorLabel renders whoever performed ev, human or service account — the
|
|
// two are mutually exclusive (migration 015). null means the server acted:
|
|
// ev.user_id == null no longer means that by itself, now that a service
|
|
// account's events also leave it null.
|
|
function actorLabel(ev, named = false) {
|
|
if (ev.user_id != null) return named ? (ev.username || 'someone') : who(ev.user_id, ev.username);
|
|
if (ev.service_account_id != null) return ev.service_account_name || 'a service account';
|
|
return null;
|
|
}
|
|
|
|
// assignerLabel is actorLabel for an 'assigned' event, whose user_id is the
|
|
// assignee: the person who made the assignment is in the actor_* fields
|
|
// (null for assignments from before they were recorded).
|
|
function assignerLabel(ev, named = false) {
|
|
if (ev.actor_user_id != null) return named ? (ev.actor_username || 'someone') : who(ev.actor_user_id, ev.actor_username);
|
|
if (ev.actor_service_account_id != null) return ev.actor_service_account_name || 'a service account';
|
|
return null;
|
|
}
|
|
|
|
// named spells users out instead of "you", for text that leaves this page.
|
|
function eventText(ev, named = false) {
|
|
const person = actorLabel(ev, named);
|
|
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': {
|
|
const by = assignerLabel(ev, named);
|
|
return by ? ['Assigned to ', strong(person), ' by ', strong(by)] : ['Assigned to ', strong(person)];
|
|
}
|
|
case 'archived': return [strong(person), ' archived the incident'];
|
|
case 'unarchived': return [strong(person), ' unarchived the incident'];
|
|
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';
|
|
// The detail is "<kind> via <channel type>"; a person with two channels
|
|
// has two of these per page, so the channel is what tells them apart.
|
|
const [kind, via] = (ev.detail || '').split(' via ');
|
|
const on = via ? ` on ${via}` : '';
|
|
if (kind === 'reminder') return ['Reminder sent to ', to, on];
|
|
if (kind === 'resolved') return ['Resolution sent to ', to, on];
|
|
// '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 (kind === 'escalated') return ['Escalation paged ', to, on];
|
|
return ['Paged ', to, on];
|
|
}
|
|
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';
|
|
|
|
// One icon per kind of event, so the rail says what happened before the text
|
|
// does. Anything not listed keeps a plain dot.
|
|
const EVENT_ICON = {
|
|
triggered: 'bell', notified: 'bell', escalated: 'arrowUp',
|
|
acknowledged: 'check', unacknowledged: 'undo', assigned: 'user',
|
|
snoozed: 'clock', unsnoozed: 'clock', resolved: 'checkCircle',
|
|
alert_added: 'plus', alert_resolved: 'check',
|
|
archived: 'archive', unarchived: 'undo',
|
|
note: 'note', resolution_note: 'note',
|
|
notify_failed: 'alertTriangle', deadman_silent: 'alertTriangle',
|
|
};
|
|
|
|
// An escalation that ran out of levels, or of anywhere to send the page, is
|
|
// the one event on the rail that means "nobody is being told": it gets the
|
|
// warning treatment instead of reading like any other step.
|
|
const isProblem = (ev) => ev.type === 'notify_failed' || ev.type === 'deadman_silent'
|
|
|| (ev.type === 'escalated' && /exhausted/i.test(ev.detail || ''));
|
|
|
|
function timelineItem(ev) {
|
|
const mine = isNote(ev) && ev.user_id === myID();
|
|
const problem = isProblem(ev);
|
|
const iconName = problem ? 'alertTriangle' : EVENT_ICON[ev.type];
|
|
return h('li', { class: `tl-item tl-${ev.type}${problem ? ' tl-problem' : ''}` },
|
|
h('span', { class: 'tl-dot' }, iconName && icon(iconName, 'icon tl-icon')),
|
|
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 the actionbar is rebuilt on every render.
|
|
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: copyIncident }, icon('copy'), 'Copy');
|
|
}
|
|
|
|
// Desktop has room to show what a phone folds into the More sheet below — see
|
|
// the .action-extra/.more-btn rules in app.css. Resolved/archived incidents
|
|
// already say everything via primaryAction()/secondaryAction(), so there is
|
|
// nothing extra to surface for them.
|
|
function extraActions() {
|
|
if (!isOpen()) return [];
|
|
const out = [
|
|
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'),
|
|
];
|
|
out.push(inc.status === 'acknowledged'
|
|
? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack')
|
|
: h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve…'));
|
|
return out;
|
|
}
|
|
|
|
function actionBar() {
|
|
const more = h('button', { class: 'btn more-btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
|
|
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), ...extraActions(), 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 {
|
|
// The bar already carries the primary action (Archive/Unarchive) and
|
|
// Copy, so the sheet adds only what it lacks.
|
|
items.push(item('note', 'Add note…', addNote));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|