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:
+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