|
|
|
@@ -66,6 +66,8 @@ function render() {
|
|
|
|
|
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) {
|
|
|
|
@@ -186,8 +188,9 @@ function alertItem(a) {
|
|
|
|
|
|
|
|
|
|
// ---------- timeline ----------
|
|
|
|
|
|
|
|
|
|
function eventText(ev) {
|
|
|
|
|
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
|
|
|
|
|
// 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);
|
|
|
|
@@ -263,6 +266,100 @@ function timelineItem(ev) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- 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';
|
|
|
|
@@ -470,10 +567,12 @@ async function moreMenu() {
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -502,6 +601,7 @@ export function key(e) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|