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:
+16
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
+11
-5
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-5
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user