Show similar earlier incidents and let notes be marked as the fix

The incident view gets a "Seen before" section from the server's new
/similar endpoint; an older server without it just shows nothing. C adds a
note as the resolution note, alongside c for a plain note. Needs the
server release that adds /similar.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
This commit is contained in:
Niklas Ye
2026-09-25 15:42:25 +02:00
parent 451ce99a62
commit 057302cb39
7 changed files with 104 additions and 24 deletions
+16 -3
View File
@@ -276,6 +276,18 @@ func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) {
return events, c.do(req, &events) return events, c.do(req, &events)
} }
// GetSimilarIncidents lists earlier resolved incidents that look like this one
// and have notes. Servers before the similar-incidents endpoint answer 404; the
// caller treats any error as "nothing to show".
func (c *Client) GetSimilarIncidents(id int64) ([]SimilarIncident, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/similar", id))
if err != nil {
return nil, err
}
var similar []SimilarIncident
return similar, c.do(req, &similar)
}
func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) { func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id)) req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
if err != nil { if err != nil {
@@ -357,10 +369,11 @@ func (c *Client) UnarchiveIncident(id int64) error {
return c.do(req, nil) return c.do(req, nil)
} }
// AddNote appends a note to the incident's timeline. // AddNote appends a note to the incident's timeline. pinned files it as the
func (c *Client) AddNote(incidentID int64, content string) (*IncidentEvent, error) { // resolution note: what fixed the incident, shown on similar ones later.
func (c *Client) AddNote(incidentID int64, content string, pinned bool) (*IncidentEvent, error) {
req, err := c.newRequestWithBody(http.MethodPost, req, err := c.newRequestWithBody(http.MethodPost,
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]string{"content": content}) fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]any{"content": content, "pinned": pinned})
if err != nil { if err != nil {
return nil, err return nil, err
} }
+3 -1
View File
@@ -165,8 +165,10 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodPost, "/api/incidents/7/archive", ""}, http.MethodPost, "/api/incidents/7/archive", ""},
{"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) }, {"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) },
http.MethodDelete, "/api/incidents/7/archive", ""}, http.MethodDelete, "/api/incidents/7/archive", ""},
{"add note", func(c *Client) error { _, err := c.AddNote(7, "hi"); return err }, {"add note", func(c *Client) error { _, err := c.AddNote(7, "hi", false); return err },
http.MethodPost, "/api/incidents/7/notes", ""}, http.MethodPost, "/api/incidents/7/notes", ""},
{"similar", func(c *Client) error { _, err := c.GetSimilarIncidents(7); return err },
http.MethodGet, "/api/incidents/7/similar", `[]`},
{"delete note", func(c *Client) error { return c.DeleteNote(7, 12) }, {"delete note", func(c *Client) error { return c.DeleteNote(7, 12) },
http.MethodDelete, "/api/incidents/7/notes/12", ""}, http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err }, {"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
+16
View File
@@ -108,6 +108,10 @@ const (
EventResolved = "resolved" EventResolved = "resolved"
EventNote = "note" EventNote = "note"
// A note marked as what fixed the incident. The server leads similar
// incidents with these.
EventResolutionNote = "resolution_note"
// Written when a team's dead man's switch stops reporting. // Written when a team's dead man's switch stops reporting.
EventDeadmanSilent = "deadman_silent" EventDeadmanSilent = "deadman_silent"
@@ -249,3 +253,15 @@ type APIKey struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"` LastUsedAt *time.Time `json:"last_used_at"`
} }
// SimilarIncident is an earlier, resolved incident with the same signature
// (alert name plus stable group labels) as the one being viewed. ResolutionNotes
// are its "what fixed it" notes; NoteCount counts its plain notes.
type SimilarIncident struct {
ID int64 `json:"id"`
Title string `json:"title"`
TriggeredAt time.Time `json:"triggered_at"`
ResolvedAt time.Time `json:"resolved_at"`
NoteCount int `json:"note_count"`
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
}
+11 -5
View File
@@ -146,6 +146,7 @@ type clearStatusMsg struct{}
type incidentDetailFetchedMsg struct { type incidentDetailFetchedMsg struct {
incident api.Incident incident api.Incident
timeline []api.IncidentEvent timeline []api.IncidentEvent
similar []api.SimilarIncident
} }
type alertDetailFetchedMsg struct{ alert api.Alert } type alertDetailFetchedMsg struct{ alert api.Alert }
type detailErrMsg struct{ err error } type detailErrMsg struct{ err error }
@@ -253,7 +254,9 @@ type Model struct {
// Incident detail // Incident detail
selectedIncident api.Incident selectedIncident api.Incident
timeline []api.IncidentEvent timeline []api.IncidentEvent
similar []api.SimilarIncident
noteCursor int noteCursor int
notePinned bool // the note being typed is the resolution note
detailLoading bool detailLoading bool
detailViewport viewport.Model detailViewport viewport.Model
@@ -614,7 +617,7 @@ func (m *Model) refreshDetailContent() {
return return
} }
m.detailViewport.SetContent( m.detailViewport.SetContent(
buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.noteCursor, m.width)) buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.similar, m.noteCursor, m.width))
} }
func (m *Model) refreshStatsContent() { func (m *Model) refreshStatsContent() {
@@ -746,7 +749,7 @@ func userFlags(u api.User) string {
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent { func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
notes := make([]api.IncidentEvent, 0, len(timeline)) notes := make([]api.IncidentEvent, 0, len(timeline))
for _, e := range timeline { for _, e := range timeline {
if e.Type == api.EventNote { if e.Type == api.EventNote || e.Type == api.EventResolutionNote {
notes = append(notes, e) notes = append(notes, e)
} }
} }
@@ -1131,7 +1134,10 @@ func incidentDetail(client *api.Client, id int64) tea.Msg {
if err != nil { if err != nil {
return detailErrMsg{err} return detailErrMsg{err}
} }
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline} // Best effort: an older server has no such endpoint, and the incident is
// still worth showing without it.
similar, _ := client.GetSimilarIncidents(id)
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline, similar: similar}
} }
func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd { func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd {
@@ -1185,9 +1191,9 @@ func unsnoozeIncidentCmd(client *api.Client, id int64) tea.Cmd {
return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) }) return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) })
} }
func addNoteCmd(client *api.Client, id int64, content string) tea.Cmd { func addNoteCmd(client *api.Client, id int64, content string, pinned bool) tea.Cmd {
return incidentActionCmd(client, id, func() error { return incidentActionCmd(client, id, func() error {
_, err := client.AddNote(id, content) _, err := client.AddNote(id, content, pinned)
return err return err
}) })
} }
+4 -2
View File
@@ -145,6 +145,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case incidentDetailFetchedMsg: case incidentDetailFetchedMsg:
m.selectedIncident = msg.incident m.selectedIncident = msg.incident
m.timeline = msg.timeline m.timeline = msg.timeline
m.similar = msg.similar
m.detailLoading = false m.detailLoading = false
if m.noteCursor >= len(noteEvents(m.timeline)) { if m.noteCursor >= len(noteEvents(m.timeline)) {
m.noteCursor = -1 m.noteCursor = -1
@@ -938,8 +939,9 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDashboard m.mode = modeDashboard
return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter) return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
case "c": case "c", "C":
m.mode = modeNote m.mode = modeNote
m.notePinned = msg.String() == "C"
m.noteInput.Reset() m.noteInput.Reset()
m.noteInput.Focus() m.noteInput.Focus()
m.detailViewport.Height = m.detailViewportHeight() m.detailViewport.Height = m.detailViewportHeight()
@@ -1032,7 +1034,7 @@ func (m Model) handleNoteKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeIncidentDetail m.mode = modeIncidentDetail
m.noteInput.Blur() m.noteInput.Blur()
m.detailViewport.Height = m.detailViewportHeight() m.detailViewport.Height = m.detailViewportHeight()
return m, addNoteCmd(m.client, m.selectedIncident.ID, content) return m, addNoteCmd(m.client, m.selectedIncident.ID, content, m.notePinned)
} }
return m, nil return m, nil
+29 -5
View File
@@ -90,7 +90,11 @@ func (m Model) renderBody() string {
case modeIncidentDetail, modeAlertDetail: case modeIncidentDetail, modeAlertDetail:
return m.renderDetail() return m.renderDetail()
case modeNote: case modeNote:
return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View()) label := "Note: "
if m.notePinned {
label = "What fixed it: "
}
return m.renderPrompt(m.styles.Header.Render(label) + m.noteInput.View())
case modeSnooze: case modeSnooze:
return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View()) return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm: case modeConfirm:
@@ -135,7 +139,7 @@ func (m Model) renderFooter() string {
switch m.mode { switch m.mode {
case modeIncidentDetail: case modeIncidentDetail:
if !m.selectedIncident.IsOpen() { if !m.selectedIncident.IsOpen() {
return withStatus(" x·archive c·note [/]·select d·del esc·back") return withStatus(" x·archive c·note C·fix note [/]·select d·del esc·back")
} }
return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del esc·back") return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del esc·back")
@@ -485,7 +489,7 @@ func line(style lipgloss.Style, s string) string {
// ── Content builders ─────────────────────────────────────────────────────── // ── Content builders ───────────────────────────────────────────────────────
func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, similar []api.SimilarIncident, cursor, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
@@ -579,6 +583,22 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
} }
b.WriteString("\n") b.WriteString("\n")
// Seen before: earlier incidents of the same kind that someone left notes on.
if len(similar) > 0 {
b.WriteString(divider(s, "Seen before", width))
for _, sim := range similar {
b.WriteString(fmt.Sprintf(" #%-6d %-44s %s\n", sim.ID, truncate(sim.Title, 44),
s.Muted.Render("resolved "+humanAgo(now, sim.ResolvedAt))))
for _, n := range sim.ResolutionNotes {
b.WriteString(" " + s.Resolved.Render("fixed: ") + n.Detail + "\n")
}
if len(sim.ResolutionNotes) == 0 {
b.WriteString(line(s.Muted, fmt.Sprintf(" %d note(s), no resolution note", sim.NoteCount)))
}
}
b.WriteString("\n")
}
// Timeline — the only history the server keeps. // Timeline — the only history the server keeps.
notes := noteEvents(timeline) notes := noteEvents(timeline)
b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width)) b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
@@ -588,7 +608,7 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
noteIndex := 0 noteIndex := 0
for _, e := range timeline { for _, e := range timeline {
when := s.Muted.Render(humanAgo(now, e.CreatedAt)) when := s.Muted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote { if e.Type != api.EventNote && e.Type != api.EventResolutionNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when)) b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue continue
} }
@@ -598,7 +618,11 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
marker = s.Selected.Render("> ") marker = s.Selected.Render("> ")
author = s.Selected.Render(e.Username) author = s.Selected.Render(e.Username)
} }
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when)) verb := " wrote"
if e.Type == api.EventResolutionNote {
verb = " noted the fix"
}
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+verb, when))
b.WriteString(" " + e.Detail + "\n") b.WriteString(" " + e.Detail + "\n")
noteIndex++ noteIndex++
} }
+25 -8
View File
@@ -57,7 +57,7 @@ func TestIncidentDetail_RendersTheWholeStory(t *testing.T) {
{Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now}, {Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now},
} }
out := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 110) out := buildIncidentDetailContent(testStyles(), inc, timeline, nil, -1, 110)
mustContain(t, out, mustContain(t, out,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL", "DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin", "Assigned:", "admin",
@@ -77,7 +77,7 @@ func TestIncidentDetail_ShowsSnooze(t *testing.T) {
} }
// The exact remaining time is humanUntil's business, not this test's — a few // The exact remaining time is humanUntil's business, not this test's — a few
// microseconds of elapsed clock turn "in 2h" into "in 1h 59m". // microseconds of elapsed clock turn "in 2h" into "in 1h 59m".
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h") "TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
} }
@@ -88,7 +88,7 @@ func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) {
Title: "Noisy", Status: api.StatusTriggered, Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &past, TriggeredAt: time.Now(), SnoozedUntil: &past,
} }
if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, -1, 110)), "Snoozed:") { if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Snoozed:") {
t.Error("an expired snooze should not be rendered") t.Error("an expired snooze should not be rendered")
} }
} }
@@ -100,18 +100,18 @@ func TestIncidentDetail_ShowsResolutionSource(t *testing.T) {
Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour), Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour),
ResolvedAt: &now, ResolutionSource: &source, ResolvedAt: &now, ResolutionSource: &source,
} }
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110),
"RESOLVED", "Resolved:", "manual") "RESOLVED", "Resolved:", "manual")
} }
func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) { func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "nobody", "not acknowledged") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "nobody", "not acknowledged")
} }
func TestIncidentDetail_EmptyTimeline(t *testing.T) { func TestIncidentDetail_EmptyTimeline(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "Nothing recorded yet") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "Nothing recorded yet")
} }
func TestIncidentDetail_MarksSelectedNote(t *testing.T) { func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
@@ -122,7 +122,7 @@ func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
} }
inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now} inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now}
out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, 1, 110)) out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, nil, 1, 110))
for _, line := range strings.Split(out, "\n") { for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") { if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") {
t.Errorf("expected the selected note marked, got %q", line) t.Errorf("expected the selected note marked, got %q", line)
@@ -212,7 +212,7 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
Detail: "reminder: ntfy returned 502", CreatedAt: now}, Detail: "reminder: ntfy returned 502", CreatedAt: now},
} }
got := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 120) got := buildIncidentDetailContent(testStyles(), inc, timeline, nil, -1, 120)
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
} }
@@ -349,3 +349,20 @@ func TestView_ZeroWidthRendersNothing(t *testing.T) {
type errFixture struct{} type errFixture struct{}
func (errFixture) Error() string { return "connection refused" } func (errFixture) Error() string { return "connection refused" }
func TestIncidentDetail_ShowsSimilarWithResolutionNotes(t *testing.T) {
now := time.Now()
inc := api.Incident{Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now}
similar := []api.SimilarIncident{
{ID: 4, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-48 * time.Hour),
ResolutionNotes: []api.IncidentEvent{{Type: api.EventResolutionNote, Detail: "rotated the logs"}}},
{ID: 2, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-96 * time.Hour), NoteCount: 3},
}
out := plain(buildIncidentDetailContent(testStyles(), inc, nil, similar, -1, 110))
mustContain(t, out, "Seen before", "#4", "fixed: rotated the logs", "3 note(s), no resolution note")
// Nothing similar, no section.
if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Seen before") {
t.Error("expected no Seen before section without similar incidents")
}
}