From 057302cb394393a98dd18c01924d3d047964880d Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Fri, 25 Sep 2026 15:42:25 +0200 Subject: [PATCH] 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 --- internal/api/client.go | 19 ++++++++++++++++--- internal/api/client_test.go | 4 +++- internal/api/types.go | 16 ++++++++++++++++ internal/tui/model.go | 16 +++++++++++----- internal/tui/update.go | 6 ++++-- internal/tui/view.go | 34 +++++++++++++++++++++++++++++----- internal/tui/view_test.go | 33 +++++++++++++++++++++++++-------- 7 files changed, 104 insertions(+), 24 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index e9a4c3e..81503e2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -276,6 +276,18 @@ func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) { 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) { req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id)) if err != nil { @@ -357,10 +369,11 @@ func (c *Client) UnarchiveIncident(id int64) error { return c.do(req, nil) } -// AddNote appends a note to the incident's timeline. -func (c *Client) AddNote(incidentID int64, content string) (*IncidentEvent, error) { +// AddNote appends a note to the incident's timeline. pinned files it as the +// 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, - 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 { return nil, err } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 7877006..934876c 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -165,8 +165,10 @@ func TestClient_IncidentEndpoints(t *testing.T) { http.MethodPost, "/api/incidents/7/archive", ""}, {"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) }, 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", ""}, + {"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) }, http.MethodDelete, "/api/incidents/7/notes/12", ""}, {"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err }, diff --git a/internal/api/types.go b/internal/api/types.go index 5ac10bd..602c3ac 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -108,6 +108,10 @@ const ( EventResolved = "resolved" 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. EventDeadmanSilent = "deadman_silent" @@ -249,3 +253,15 @@ type APIKey struct { CreatedAt time.Time `json:"created_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"` +} diff --git a/internal/tui/model.go b/internal/tui/model.go index fd8523c..b1c4f56 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -146,6 +146,7 @@ type clearStatusMsg struct{} type incidentDetailFetchedMsg struct { incident api.Incident timeline []api.IncidentEvent + similar []api.SimilarIncident } type alertDetailFetchedMsg struct{ alert api.Alert } type detailErrMsg struct{ err error } @@ -253,7 +254,9 @@ type Model struct { // Incident detail selectedIncident api.Incident timeline []api.IncidentEvent + similar []api.SimilarIncident noteCursor int + notePinned bool // the note being typed is the resolution note detailLoading bool detailViewport viewport.Model @@ -614,7 +617,7 @@ func (m *Model) refreshDetailContent() { return } 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() { @@ -746,7 +749,7 @@ func userFlags(u api.User) string { func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent { notes := make([]api.IncidentEvent, 0, len(timeline)) for _, e := range timeline { - if e.Type == api.EventNote { + if e.Type == api.EventNote || e.Type == api.EventResolutionNote { notes = append(notes, e) } } @@ -1131,7 +1134,10 @@ func incidentDetail(client *api.Client, id int64) tea.Msg { if err != nil { 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 { @@ -1185,9 +1191,9 @@ func unsnoozeIncidentCmd(client *api.Client, id int64) tea.Cmd { 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 { - _, err := client.AddNote(id, content) + _, err := client.AddNote(id, content, pinned) return err }) } diff --git a/internal/tui/update.go b/internal/tui/update.go index ea22f0f..e3e8b76 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -145,6 +145,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case incidentDetailFetchedMsg: m.selectedIncident = msg.incident m.timeline = msg.timeline + m.similar = msg.similar m.detailLoading = false if m.noteCursor >= len(noteEvents(m.timeline)) { m.noteCursor = -1 @@ -938,8 +939,9 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.mode = modeDashboard return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter) - case "c": + case "c", "C": m.mode = modeNote + m.notePinned = msg.String() == "C" m.noteInput.Reset() m.noteInput.Focus() m.detailViewport.Height = m.detailViewportHeight() @@ -1032,7 +1034,7 @@ func (m Model) handleNoteKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.mode = modeIncidentDetail m.noteInput.Blur() 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 diff --git a/internal/tui/view.go b/internal/tui/view.go index 069e349..8a338c5 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -90,7 +90,11 @@ func (m Model) renderBody() string { case modeIncidentDetail, modeAlertDetail: return m.renderDetail() 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: return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View()) case modeConfirm: @@ -135,7 +139,7 @@ func (m Model) renderFooter() string { switch m.mode { case modeIncidentDetail: 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") @@ -485,7 +489,7 @@ func line(style lipgloss.Style, s string) string { // ── 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() var b strings.Builder contentW := width - 4 @@ -579,6 +583,22 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid } 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. notes := noteEvents(timeline) 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 for _, e := range timeline { 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)) continue } @@ -598,7 +618,11 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid marker = s.Selected.Render("> ") 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") noteIndex++ } diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 78788a4..c33cc2c 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -57,7 +57,7 @@ func TestIncidentDetail_RendersTheWholeStory(t *testing.T) { {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, "DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL", "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 // 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") } @@ -88,7 +88,7 @@ func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) { Title: "Noisy", Status: api.StatusTriggered, 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") } } @@ -100,18 +100,18 @@ func TestIncidentDetail_ShowsResolutionSource(t *testing.T) { Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour), ResolvedAt: &now, ResolutionSource: &source, } - mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), + mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "RESOLVED", "Resolved:", "manual") } func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) { 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) { 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) { @@ -122,7 +122,7 @@ func TestIncidentDetail_MarksSelectedNote(t *testing.T) { } 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") { if strings.Contains(line, "alice") && !strings.HasPrefix(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}, } - 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") } @@ -349,3 +349,20 @@ func TestView_ZeroWidthRendersNothing(t *testing.T) { type errFixture struct{} 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") + } +}