591d5b8df0
A button in the incident header (also `y`, and "Copy incident" in the more menu) puts everything the page knows on the clipboard, for pasting into a chat or an agent prompt with no integration involved. The text carries the facts, every alert with all its labels and annotations (the page only shows summary or description), the timeline with notes in full, and the "Seen before" resolution notes. Times are ISO 8601 and users are named rather than "you", since relative and first-person wording is ambiguous once pasted elsewhere. The async clipboard API needs a secure context and this server is often reached over plain HTTP, so it falls back to execCommand. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
609 lines
23 KiB
JavaScript
609 lines
23 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 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()),
|
|
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));
|
|
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
|
|
if (inc.acknowledged_at) {
|
|
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
|
|
}
|
|
add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned');
|
|
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
|
add('Snoozed until', when(inc.snoozed_until));
|
|
}
|
|
if (inc.escalation_level > 0 && inc.escalation_due_at) {
|
|
add('Escalates next', when(inc.escalation_due_at),
|
|
h('span', { class: 'sub', text: ` · level ${inc.escalation_level}` }));
|
|
}
|
|
if (inc.resolved_at) {
|
|
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
|
|
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
|
|
}
|
|
if (inc.archived_at) add('Archived', when(inc.archived_at));
|
|
return h('div', { class: 'card' }, h('dl', { class: 'facts' }, rows));
|
|
}
|
|
|
|
function groupLabels() {
|
|
const entries = Object.entries(inc.group_labels || {});
|
|
if (!entries.length) return null;
|
|
return h('section', { class: 'section' },
|
|
h('h2', { class: 'section-title', text: 'Grouped by' }),
|
|
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))),
|
|
);
|
|
}
|
|
|
|
function alertsSection() {
|
|
const list = inc.alerts || [];
|
|
const firing = list.filter((a) => a.status === 'firing').length;
|
|
return h('section', { class: 'section' },
|
|
h('h2', { class: 'section-title' },
|
|
h('span', { text: `Alerts (${list.length})` }),
|
|
firing ? h('span', { text: `${firing} firing` }) : null),
|
|
list.length
|
|
? h('div', { class: 'card' }, list.map(alertItem))
|
|
: h('div', { class: 'card card-pad', text: 'No alerts attached.' }),
|
|
);
|
|
}
|
|
|
|
function alertItem(a) {
|
|
const summary = (a.annotations && (a.annotations.summary || a.annotations.description)) || '';
|
|
const labels = Object.entries(a.labels || {});
|
|
return h('div', { class: 'alert-item' },
|
|
h('div', { class: 'alert-item-head' },
|
|
h('span', { class: 'alert-item-name', text: a.name }),
|
|
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`)),
|
|
summary && h('div', { class: 'alert-item-summary', text: summary }),
|
|
h('div', { class: 'alert-item-foot' },
|
|
h('span', { text: `Started ${ago(a.starts_at)}` }),
|
|
h('span', { text: `Last seen ${ago(a.received_at)}` }),
|
|
a.generator_url && h('a', { href: a.generator_url, target: '_blank', rel: 'noopener noreferrer' }, 'Source ↗'),
|
|
),
|
|
labels.length > 0 && h('details', {},
|
|
h('summary', { text: `${labels.length} labels` }),
|
|
h('div', { class: 'labels-wrap' }, labels.map(([k, v]) => labelChip(k, v)))),
|
|
);
|
|
}
|
|
|
|
// ---------- timeline ----------
|
|
|
|
// 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'];
|
|
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];
|
|
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 || '' })),
|
|
)))),
|
|
);
|
|
}
|
|
|
|
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', '')),
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
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 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;
|
|
}
|
|
}
|