Copy an incident to the clipboard as Markdown
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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user