diff --git a/internal/web/incident_test.go b/internal/web/incident_test.go new file mode 100644 index 0000000..e0ea6c3 --- /dev/null +++ b/internal/web/incident_test.go @@ -0,0 +1,26 @@ +package web + +import ( + "io/fs" + "strings" + "testing" +) + +func TestCopyIncidentIsEmbedded(t *testing.T) { + sub, err := fs.Sub(files, "static") + if err != nil { + t.Fatal(err) + } + for file, want := range map[string]string{ + "js/incident.js": "copyIncident", + "js/ui.js": "copy:", + } { + b, err := fs.ReadFile(sub, file) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), want) { + t.Errorf("%s lacks %s", file, want) + } + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index c0e3200..422d241 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -377,6 +377,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh -webkit-backdrop-filter: saturate(1.4) blur(12px); border-bottom: 1px solid var(--border); } +.detail-head .copy { margin-left: auto; } +.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; } .detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; } .detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; } .detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } diff --git a/internal/web/static/js/incident.js b/internal/web/static/js/incident.js index c0f8381..82e74df 100644 --- a/internal/web/static/js/incident.js +++ b/internal/web/static/js/incident.js @@ -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; } diff --git a/internal/web/static/js/ui.js b/internal/web/static/js/ui.js index f97df18..00c8f3e 100644 --- a/internal/web/static/js/ui.js +++ b/internal/web/static/js/ui.js @@ -48,6 +48,7 @@ const ICONS = { note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'], archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'], flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'], + copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'], trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'], chevronLeft: ['M15 18l-6-6 6-6'], chevronRight: ['M9 6l6 6-6 6'],