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)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user