Show notes from similar earlier incidents

Each incident gets a signature: the alert name plus the group labels that
say what is broken, minus the ones that only say where it ran (instance,
pod, container, ...). GET /api/incidents/{id}/similar returns resolved
incidents in the same team with the same signature that have notes.

Notes can be marked as the resolution note, "what fixed it", either with a
resolution field on resolve or pinned on a note. Those lead the similar
list, show on the incident page as "Seen before", and the triggered
notification carries the latest one.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
This commit is contained in:
Niklas Ye
2026-09-25 15:42:25 +02:00
parent 734cd9c5fd
commit 60ebb75cd2
12 changed files with 400 additions and 23 deletions
+3 -2
View File
@@ -83,13 +83,14 @@ export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
export const resolve = (id) => call('POST', `/incidents/${id}/resolve`);
export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {});
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
// stats
+61 -13
View File
@@ -17,6 +17,7 @@ const pane = () => document.getElementById('detail');
let currentID = null;
let inc = null;
let events = [];
let similarList = [];
let error = null;
let busy = false;
@@ -38,10 +39,15 @@ export async function refresh() {
const id = currentID;
if (id == null) return;
try {
const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]);
// 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;
@@ -81,6 +87,7 @@ function render() {
facts(),
groupLabels(),
alertsSection(),
similarSection(),
timelineSection(),
),
actionBar(),
@@ -197,6 +204,7 @@ function eventText(ev) {
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];
@@ -209,6 +217,22 @@ function eventText(ev) {
}
}
// 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' },
@@ -223,14 +247,16 @@ function timelineSection() {
);
}
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
function timelineItem(ev) {
const mine = ev.type === 'note' && ev.user_id === myID();
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)}` }),
ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }),
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')),
),
@@ -296,15 +322,35 @@ function unacknowledge() {
async function resolve() {
const id = inc.id;
const ok = await confirm({
title: 'Resolve this incident?',
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.',
confirmLabel: 'Resolve',
danger: true,
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 (ok) await run(() => api.resolve(id), 'Resolved');
if (res) await run(() => api.resolve(id, res.resolution), 'Resolved');
}
function archive() {
@@ -382,16 +428,18 @@ async function addNote() {
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(v);
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' })),
@@ -402,7 +450,7 @@ async function addNote() {
});
return form;
});
if (content) await run(() => api.addNote(id, content), 'Note added');
if (content) await run(() => api.addNote(id, content.content, content.pinned), 'Note added');
}
async function deleteNote(ev) {