From 60ebb75cd29e4dbf90d6ab1fe83cb9366b4250df Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Fri, 25 Sep 2026 15:42:25 +0200 Subject: [PATCH] Show notes from similar earlier incidents Each incident gets a signature: the alert name plus the group labels that say what is broken, minus the ones that only say where it ran (instance, pod, container, ...). GET /api/incidents/{id}/similar returns resolved incidents in the same team with the same signature that have notes. Notes can be marked as the resolution note, "what fixed it", either with a resolution field on resolve or pinned on a note. Those lead the similar list, show on the incident page as "Seen before", and the triggered notification carries the latest one. Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU --- internal/api/alertmanager.go | 6 +- internal/api/incident_store.go | 32 +++++ internal/api/incidents.go | 30 ++++- internal/api/notifier.go | 21 ++++ internal/api/router.go | 1 + internal/api/similar.go | 113 ++++++++++++++++++ internal/api/similar_test.go | 99 +++++++++++++++ .../db/migrations/008_incident_signature.sql | 23 ++++ internal/models/incident.go | 12 ++ internal/web/static/app.css | 7 +- internal/web/static/js/api.js | 5 +- internal/web/static/js/incident.js | 74 ++++++++++-- 12 files changed, 400 insertions(+), 23 deletions(-) create mode 100644 internal/api/similar.go create mode 100644 internal/api/similar_test.go create mode 100644 internal/db/migrations/008_incident_signature.sql diff --git a/internal/api/alertmanager.go b/internal/api/alertmanager.go index a630b77..4722a46 100644 --- a/internal/api/alertmanager.go +++ b/internal/api/alertmanager.go @@ -384,10 +384,10 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in var id int64 err = q.QueryRowContext(ctx, ` - INSERT INTO incidents (team_id, group_key, title, group_labels, status, severity, triggered_at, assigned_to) - VALUES ($1, $2, $3, $4::jsonb, 'triggered', $5, $6, $7) + INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to) + VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8) RETURNING id`, - teamID, groupKey, title, string(labelsJSON), severity, + teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity, time.Now().Unix(), onCall).Scan(&id) if err != nil { return 0, err diff --git a/internal/api/incident_store.go b/internal/api/incident_store.go index 955f9ca..0900697 100644 --- a/internal/api/incident_store.go +++ b/internal/api/incident_store.go @@ -36,6 +36,10 @@ const ( evUnsnoozed = "unsnoozed" evResolved = "resolved" evNote = "note" + // evResolutionNote is the note worth finding again: what fixed it. The + // similar-incidents lookup and the page lead with these; plain notes are + // the working chatter and stay one click away. + evResolutionNote = "resolution_note" evDeadmanSilent = "deadman_silent" ) @@ -295,6 +299,34 @@ func openIncidentForAlert(ctx context.Context, q querier, alertID int64) (int64, return id, err } +// volatileLabels say where a problem ran this time, not what the problem is, so +// they stay out of the signature. Migration 008's backfill lists the same set. +var volatileLabels = map[string]bool{ + "instance": true, "pod": true, "pod_name": true, "pod_ip": true, + "container": true, "container_name": true, "endpoint": true, +} + +// incidentSignature identifies "the same problem" across incidents: the alert +// name plus the stable group labels, sorted. Incidents in one team with equal +// signatures are what the similar-incidents lookup returns. title stands in for +// the name when the payload carried no alertname (groupless and dead man's +// switch incidents). +func incidentSignature(groupLabels map[string]string, title string) string { + name := groupLabels["alertname"] + if name == "" { + name = title + } + rest := make([]string, 0, len(groupLabels)) + for k, v := range groupLabels { + if k == "alertname" || volatileLabels[k] { + continue + } + rest = append(rest, k+"="+v) + } + sort.Strings(rest) + return name + "|" + strings.Join(rest, ",") +} + // incidentTitle renders a human-readable title from Alertmanager's groupLabels, // leading with the alert name and appending whatever else the operator grouped // by. Falls back to the alert's own name when the payload carried no groupLabels. diff --git a/internal/api/incidents.go b/internal/api/incidents.go index 1e353d8..5287f17 100644 --- a/internal/api/incidents.go +++ b/internal/api/incidents.go @@ -3,6 +3,7 @@ package api import ( "database/sql" "fmt" + "io" "net/http" "strconv" "strings" @@ -237,6 +238,15 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc { return } user, _ := userFromContext(r.Context()) + // The body is optional: clients that predate resolution notes send none. + var req struct { + Resolution string `json:"resolution"` + } + if err := decodeJSON(r, &req); err != nil && err != io.EOF { + respond(w, http.StatusBadRequest, errResp("invalid request body")) + return + } + req.Resolution = strings.TrimSpace(req.Resolution) if !updateOpenIncident(w, r, db, id, `UPDATE incidents SET status = 'resolved', resolved_at = $1, resolution_source = $2 WHERE id = $3 AND resolved_at IS NULL`, @@ -252,6 +262,12 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc { respond(w, http.StatusInternalServerError, errResp("internal error")) return } + if req.Resolution != "" { + if err := logEvent(r.Context(), db, id, evResolutionNote, &user.ID, nil, &req.Resolution); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + } respondIncident(w, r, db, id) } } @@ -421,11 +437,17 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc { } var req struct { Content string `json:"content"` + // Pinned files the note as the resolution note: what fixed it. + Pinned bool `json:"pinned"` } if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) return } + noteType := evNote + if req.Pinned { + noteType = evResolutionNote + } if req.Content == "" { respond(w, http.StatusBadRequest, errResp("content is required")) return @@ -440,7 +462,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc { err := db.QueryRowContext(r.Context(), ` INSERT INTO incident_events (incident_id, type, user_id, detail, created_at) VALUES ($1, $2, $3, $4, $5) - RETURNING id`, id, evNote, user.ID, req.Content, now.Unix()).Scan(&eventID) + RETURNING id`, id, noteType, user.ID, req.Content, now.Unix()).Scan(&eventID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -449,7 +471,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc { respond(w, http.StatusCreated, models.IncidentEvent{ ID: eventID, IncidentID: id, - Type: evNote, + Type: noteType, UserID: &user.ID, Username: &user.Username, Detail: &req.Content, @@ -475,8 +497,8 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc { user, _ := userFromContext(r.Context()) res, err := db.ExecContext(r.Context(), ` DELETE FROM incident_events - WHERE id = $1 AND incident_id = $2 AND type = $3 AND user_id = $4`, - eventID, id, evNote, user.ID) + WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4) AND user_id = $5`, + eventID, id, evNote, evResolutionNote, user.ID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return diff --git a/internal/api/notifier.go b/internal/api/notifier.go index 51fd2b5..37d6f86 100644 --- a/internal/api/notifier.go +++ b/internal/api/notifier.go @@ -330,6 +330,16 @@ func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) err msg := renderNotification(inc, n, firing, cfg) + // The page that opens an incident carries what fixed it last time, so the + // person woken up starts from that. Best effort: a failed lookup must not + // hold back the page itself. + if n.kind == notifyTriggered { + if sim, err := similarIncidents(ctx, db, n.incidentID, 1); err == nil && len(sim) > 0 && len(sim[0].ResolutionNotes) > 0 { + notes := sim[0].ResolutionNotes + msg.Message += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160) + } + } + // An Acknowledge button needs both a user to attribute the acknowledgement // to and a URL the phone can reach. Minted per delivery, so every push // carries its own short-lived token rather than reusing one. @@ -573,6 +583,17 @@ func plural(n int) string { return "s" } +// shorten cuts s to at most n runes, marking the cut, and flattens newlines so +// a multi-line note stays one line in a push. +func shorten(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} + // derefString reads a nullable text column as a plain string. func derefString(s *string) string { if s == nil { diff --git a/internal/api/router.go b/internal/api/router.go index 02703f9..fd55d25 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -112,6 +112,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler r.Get("/api/incidents/{id}", handleGetIncident(db)) r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db)) r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db)) + r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db)) r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db)) r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db)) r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db)) diff --git a/internal/api/similar.go b/internal/api/similar.go new file mode 100644 index 0000000..b316289 --- /dev/null +++ b/internal/api/similar.go @@ -0,0 +1,113 @@ +package api + +import ( + "context" + "database/sql" + "net/http" + "strconv" + "time" + + "git.ryuvia.com/niklas/terdut-server/internal/models" +) + +const ( + similarDefaultLimit = 5 + similarMaxLimit = 20 +) + +// handleIncidentSimilar lists earlier, resolved incidents in the same team with +// the same signature that someone left notes on, incidents with a resolution +// note first. This is the "have we seen this before" answer for a responder +// looking at a fresh incident; the plain notes are one timeline fetch away. +func handleIncidentSimilar(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, ok := incidentIDParam(w, r, db) + if !ok { + return + } + limit := similarDefaultLimit + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + respond(w, http.StatusBadRequest, errResp("invalid limit")) + return + } + limit = min(n, similarMaxLimit) + } + + out, err := similarIncidents(r.Context(), db, id, limit) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, out) + } +} + +func similarIncidents(ctx context.Context, q querier, id int64, limit int) ([]models.SimilarIncident, error) { + rows, err := q.QueryContext(ctx, ` + SELECT o.id, o.title, o.triggered_at, o.resolved_at, + (SELECT COUNT(*) FROM incident_events e + WHERE e.incident_id = o.id AND e.type = $3) + FROM incidents i + JOIN incidents o ON o.team_id = i.team_id AND o.signature = i.signature + WHERE i.id = $1 AND o.id <> i.id AND o.resolved_at IS NOT NULL + AND EXISTS (SELECT 1 FROM incident_events e + WHERE e.incident_id = o.id AND e.type IN ($3, $4)) + ORDER BY EXISTS (SELECT 1 FROM incident_events e + WHERE e.incident_id = o.id AND e.type = $4) DESC, + o.triggered_at DESC + LIMIT $2`, id, limit, evNote, evResolutionNote) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.SimilarIncident{} + ids := []int64{} + for rows.Next() { + var s models.SimilarIncident + var triggered, resolved int64 + if err := rows.Scan(&s.ID, &s.Title, &triggered, &resolved, &s.NoteCount); err != nil { + return nil, err + } + s.TriggeredAt = time.Unix(triggered, 0).UTC() + s.ResolvedAt = time.Unix(resolved, 0).UTC() + s.ResolutionNotes = []models.IncidentEvent{} + out = append(out, s) + ids = append(ids, s.ID) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(out) == 0 { + return out, nil + } + + nrows, err := q.QueryContext(ctx, ` + SELECT e.id, e.incident_id, e.type, e.user_id, u.username, e.detail, e.created_at + FROM incident_events e + LEFT JOIN users u ON u.id = e.user_id + WHERE e.incident_id = ANY($1) AND e.type = $2 + ORDER BY e.created_at, e.id`, ids, evResolutionNote) + if err != nil { + return nil, err + } + defer nrows.Close() + + byID := make(map[int64]*models.SimilarIncident, len(out)) + for i := range out { + byID[out[i].ID] = &out[i] + } + for nrows.Next() { + var e models.IncidentEvent + var ts int64 + if err := nrows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, &e.Detail, &ts); err != nil { + return nil, err + } + e.CreatedAt = time.Unix(ts, 0).UTC() + s := byID[e.IncidentID] + s.ResolutionNotes = append(s.ResolutionNotes, e) + } + return out, nrows.Err() +} diff --git a/internal/api/similar_test.go b/internal/api/similar_test.go new file mode 100644 index 0000000..02d5de6 --- /dev/null +++ b/internal/api/similar_test.go @@ -0,0 +1,99 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "strconv" + "testing" +) + +// postGrouped posts a firing webhook whose group labels are exactly the given +// map, unlike postWebhook, which only ever groups by alertname. +func postGrouped(t *testing.T, s *ts, fingerprint, startsAt string, groupLabels map[string]string) { + t.Helper() + labels := map[string]string{} + for k, v := range groupLabels { + labels[k] = v + } + payload := map[string]any{ + "version": "4", "status": "firing", + "groupKey": fingerprint, + "groupLabels": groupLabels, + "alerts": []map[string]any{ + amAlert(fingerprint, groupLabels["alertname"], "firing", startsAt, zeroTime, labels), + }, + } + data, _ := json.Marshal(payload) + resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager", + "application/json", bytes.NewReader(data)) + if err != nil { + t.Fatalf("post webhook: %v", err) + } + resp.Body.Close() +} + +func similar(t *testing.T, s *ts, id int) []map[string]any { + t.Helper() + var out []map[string]any + decode(t, s.req(t, http.MethodGet, "/api/incidents/"+strconv.Itoa(id)+"/similar", nil), &out) + return out +} + +// Same alert on another instance is the same problem; a resolution note left on +// the first one is what the second one should be shown. +func TestSimilar_IgnoresVolatileLabelsAndLeadsWithResolutionNote(t *testing.T) { + s := newTS(t) + postGrouped(t, s, "fp-a", "2026-05-20T10:00:00Z", + map[string]string{"alertname": "DiskFull", "instance": "web-1", "job": "node"}) + s.req(t, http.MethodPost, "/api/incidents/1/resolve", + map[string]string{"resolution": "rotated the logs"}).Body.Close() + + postGrouped(t, s, "fp-b", "2026-05-21T10:00:00Z", + map[string]string{"alertname": "DiskFull", "instance": "web-2", "job": "node"}) + + got := similar(t, s, 2) + if len(got) != 1 || int(got[0]["id"].(float64)) != 1 { + t.Fatalf("expected incident 1 as the only similar one, got %v", got) + } + notes := got[0]["resolution_notes"].([]any) + if len(notes) != 1 || notes[0].(map[string]any)["detail"] != "rotated the logs" { + t.Fatalf("expected the resolution note, got %v", notes) + } +} + +// A different stable label (job) is a different problem, and an incident nobody +// wrote a note on has nothing to show. +func TestSimilar_DifferentSignatureOrNoNotesIsExcluded(t *testing.T) { + s := newTS(t) + postGrouped(t, s, "fp-1", "2026-05-20T10:00:00Z", + map[string]string{"alertname": "DiskFull", "job": "node"}) + s.req(t, http.MethodPost, "/api/incidents/1/notes", map[string]string{"content": "checked"}).Body.Close() + s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close() + + postGrouped(t, s, "fp-2", "2026-05-20T11:00:00Z", + map[string]string{"alertname": "DiskFull", "job": "db"}) + s.req(t, http.MethodPost, "/api/incidents/2/resolve", nil).Body.Close() + + postGrouped(t, s, "fp-3", "2026-05-21T10:00:00Z", + map[string]string{"alertname": "DiskFull", "job": "db"}) + + // Incident 3 matches 2 by signature, but 2 has no notes. + if got := similar(t, s, 3); len(got) != 0 { + t.Fatalf("expected nothing similar to incident 3, got %v", got) + } +} + +// An open incident is not "earlier experience" yet, and the incident itself is +// never its own match. +func TestSimilar_OpenIncidentsAreNotListed(t *testing.T) { + s := newTS(t) + postGrouped(t, s, "fp-o1", "2026-05-20T10:00:00Z", map[string]string{"alertname": "Flap"}) + s.req(t, http.MethodPost, "/api/incidents/1/notes", + map[string]any{"content": "still open", "pinned": true}).Body.Close() + postGrouped(t, s, "fp-o2", "2026-05-21T10:00:00Z", map[string]string{"alertname": "Flap"}) + + if got := similar(t, s, 2); len(got) != 0 { + t.Fatalf("expected an open incident not to be listed, got %v", got) + } +} diff --git a/internal/db/migrations/008_incident_signature.sql b/internal/db/migrations/008_incident_signature.sql new file mode 100644 index 0000000..3e63b31 --- /dev/null +++ b/internal/db/migrations/008_incident_signature.sql @@ -0,0 +1,23 @@ +-- Similar incidents: a signature per incident, so "has this happened before" +-- is an indexed equality instead of a search. +-- +-- The signature is the alert name plus the group labels that identify WHAT is +-- broken, minus the ones that only say WHERE it happened to run this time +-- (instance, pod, ...). Two incidents with the same signature in the same team +-- are the same problem for a responder's purposes. +-- +-- Computed in Go for new incidents (incidentSignature in incident_store.go). +-- The backfill below MUST produce the same string; keep the volatile list in +-- both places in step. +ALTER TABLE incidents ADD COLUMN signature TEXT NOT NULL DEFAULT ''; + +UPDATE incidents SET signature = + COALESCE(NULLIF(group_labels->>'alertname', ''), title) || '|' || + COALESCE(( + SELECT string_agg(e.k || '=' || e.v, ',' ORDER BY e.k) + FROM jsonb_each_text(incidents.group_labels) AS e(k, v) + WHERE e.k <> 'alertname' + AND e.k NOT IN ('instance', 'pod', 'pod_name', 'pod_ip', 'container', 'container_name', 'endpoint') + ), ''); + +CREATE INDEX incidents_signature_idx ON incidents(team_id, signature, triggered_at DESC); diff --git a/internal/models/incident.go b/internal/models/incident.go index bacb4b6..9fe05bf 100644 --- a/internal/models/incident.go +++ b/internal/models/incident.go @@ -79,3 +79,15 @@ type IncidentEvent struct { Detail *string `json:"detail,omitempty"` CreatedAt time.Time `json:"created_at"` } + +// SimilarIncident is an earlier, resolved incident with the same signature as +// the one being looked at. ResolutionNotes are the "what fixed it" notes; +// NoteCount counts the plain working notes, which live on the timeline. +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/web/static/app.css b/internal/web/static/app.css index 29a860c..dfb9a2b 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -450,11 +450,16 @@ details[open] > summary { margin-bottom: 8px; } .tl-text { overflow-wrap: anywhere; } .tl-text .who { font-weight: 650; } .tl-time { color: var(--faint); font-size: 12px; } -.tl-note .note { +.note { margin-top: 6px; padding: 10px 12px; background: var(--surface-2); border-radius: var(--radius-sm); white-space: pre-wrap; overflow-wrap: anywhere; } +.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); } +.similar { list-style: none; margin: 0; padding: 0; } +.similar-item { padding: 10px 0; } +.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); } +.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); } .note-actions { display: flex; justify-content: flex-end; } .note-actions .btn { color: var(--muted); } diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index 2bfcbc6..396b5bc 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -83,13 +83,14 @@ export const timeline = (id) => call('GET', `/incidents/${id}/timeline`); export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`); export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`); -export const resolve = (id) => call('POST', `/incidents/${id}/resolve`); +export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {}); export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } }); export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec }); export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`); export const archive = (id) => call('POST', `/incidents/${id}/archive`); export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`); -export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } }); +export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } }); +export const similar = (id) => call('GET', `/incidents/${id}/similar`); export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`); // stats diff --git a/internal/web/static/js/incident.js b/internal/web/static/js/incident.js index d630004..c0f8381 100644 --- a/internal/web/static/js/incident.js +++ b/internal/web/static/js/incident.js @@ -17,6 +17,7 @@ const pane = () => document.getElementById('detail'); let currentID = null; let inc = null; let events = []; +let similarList = []; let error = null; let busy = false; @@ -38,10 +39,15 @@ export async function refresh() { const id = currentID; if (id == null) return; try { - const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]); + // Similar incidents are a courtesy: an older server answers 404 and a + // failure here must not hide the incident itself. + const [i, t, sim] = await Promise.all([ + api.incident(id), api.timeline(id), api.similar(id).catch(() => []), + ]); if (id !== currentID) return; inc = i; events = t; + similarList = sim; error = null; } catch (err) { if (id !== currentID) return; @@ -81,6 +87,7 @@ function render() { facts(), groupLabels(), alertsSection(), + similarSection(), timelineSection(), ), actionBar(), @@ -197,6 +204,7 @@ function eventText(ev) { case 'unsnoozed': return [strong(person), ' ended the snooze']; case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing']; case 'note': return [strong(person), ' added a note']; + case 'resolution_note': return [strong(person), ' noted what fixed it']; case 'notified': { const to = person ? strong(person) : 'the fallback topic'; if (ev.detail === 'reminder') return ['Reminder sent to ', to]; @@ -209,6 +217,22 @@ function eventText(ev) { } } +// Earlier incidents with the same signature that someone left notes on, the +// ones that recorded what fixed it first. Plain notes are on that incident's +// own page. +function similarSection() { + if (!similarList.length) return null; + return h('section', { class: 'section' }, + h('h2', { class: 'section-title' }, h('span', { text: 'Seen before' })), + h('div', { class: 'card' }, + h('ul', { class: 'similar' }, similarList.map((s) => h('li', { class: 'similar-item' }, + h('a', { href: `/incidents/${s.id}`, text: `#${s.id} ${s.title}` }), + h('div', { class: 'sub', text: `${when(s.resolved_at)} · ${ago(s.resolved_at)}${s.note_count ? ` · ${s.note_count} note${s.note_count === 1 ? '' : 's'}` : ''}` }), + ...s.resolution_notes.map((n) => h('div', { class: 'note note-fix', text: n.detail || '' })), + )))), + ); +} + function timelineSection() { const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id); return h('section', { class: 'section' }, @@ -223,14 +247,16 @@ function timelineSection() { ); } +const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note'; + function timelineItem(ev) { - const mine = ev.type === 'note' && ev.user_id === myID(); + const mine = isNote(ev) && ev.user_id === myID(); return h('li', { class: `tl-item tl-${ev.type}` }, h('span', { class: 'tl-dot' }), h('div', { class: 'tl-body' }, h('div', { class: 'tl-text' }, eventText(ev)), h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }), - ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }), + isNote(ev) && h('div', { class: ev.type === 'resolution_note' ? 'note note-fix' : 'note', text: ev.detail || '' }), mine && h('div', { class: 'note-actions' }, h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')), ), @@ -296,15 +322,35 @@ function unacknowledge() { async function resolve() { const id = inc.id; - const ok = await confirm({ - title: 'Resolve this incident?', - text: 'Resolving is final. If these alerts fire again they open a new incident, ' - + 'and if any are still firing this one stays closed regardless. ' - + 'Use snooze if you only need it out of the way.', - confirmLabel: 'Resolve', - danger: true, + const res = await openSheet(() => { + const textarea = h('textarea', { + name: 'resolution', autofocus: true, maxlength: '10000', + placeholder: 'What fixed it? Optional, shown on the next similar incident.', + }); + const form = h('form', { + class: 'sheet-form', + onsubmit: (e) => { + e.preventDefault(); + closeSheet({ resolution: textarea.value.trim() }); + }, + }, + h('h2', { class: 'sheet-title', text: 'Resolve this incident?' }), + h('p', { + text: 'Resolving is final. If these alerts fire again they open a new incident, ' + + 'and if any are still firing this one stays closed regardless. ' + + 'Use snooze if you only need it out of the way.', + }), + textarea, + h('div', { class: 'sheet-actions' }, + h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }), + h('button', { class: 'btn btn-danger', type: 'submit', text: 'Resolve' })), + ); + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit(); + }); + return form; }); - if (ok) await run(() => api.resolve(id), 'Resolved'); + if (res) await run(() => api.resolve(id, res.resolution), 'Resolved'); } function archive() { @@ -382,16 +428,18 @@ async function addNote() { const textarea = h('textarea', { name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000', }); + const fix = h('input', { type: 'checkbox', name: 'fix' }); const form = h('form', { class: 'sheet-form', onsubmit: (e) => { e.preventDefault(); const v = textarea.value.trim(); - if (v) closeSheet(v); + if (v) closeSheet({ content: v, pinned: fix.checked }); }, }, h('h2', { class: 'sheet-title', text: 'Add note' }), textarea, + h('label', { class: 'check' }, fix, ' This is what fixed it (shown on similar incidents)'), h('div', { class: 'sheet-actions' }, h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }), h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })), @@ -402,7 +450,7 @@ async function addNote() { }); return form; }); - if (content) await run(() => api.addNote(id, content), 'Note added'); + if (content) await run(() => api.addNote(id, content.content, content.pinned), 'Note added'); } async function deleteNote(ev) {