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
This commit is contained in:
Niklas Ye
2026-09-25 15:42:25 +02:00
parent 734cd9c5fd
commit 60ebb75cd2
12 changed files with 400 additions and 23 deletions
+3 -3
View File
@@ -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
+32
View File
@@ -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.
+26 -4
View File
@@ -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
+21
View File
@@ -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 {
+1
View File
@@ -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))
+113
View File
@@ -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()
}
+99
View File
@@ -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)
}
}
@@ -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);
+12
View File
@@ -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"`
}
+6 -1
View File
@@ -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); }
+3 -2
View File
@@ -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
+61 -13
View File
@@ -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) {