Compare commits

...

10 Commits

Author SHA1 Message Date
Niklas Ye 3ee8583f6f Set the chart's placeholder version to 0.23.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m35s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 57s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as d2cdcc9 and 71d7e18, so a
tree heading for v0.23.0 doesn't say 0.22.1.
2026-09-25 19:12:33 +02:00
Niklas Ye 591d5b8df0 Copy an incident to the clipboard as Markdown
A button in the incident header (also `y`, and "Copy incident" in the
more menu) puts everything the page knows on the clipboard, for pasting
into a chat or an agent prompt with no integration involved.

The text carries the facts, every alert with all its labels and
annotations (the page only shows summary or description), the timeline
with notes in full, and the "Seen before" resolution notes. Times are
ISO 8601 and users are named rather than "you", since relative and
first-person wording is ambiguous once pasted elsewhere.

The async clipboard API needs a secure context and this server is often
reached over plain HTTP, so it falls back to execCommand.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-09-25 19:12:33 +02:00
Niklas Ye d2cdcc9776 Set the chart's placeholder version to 0.22.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m45s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 59s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 71d7e18 and 734cd9c, so a
tree heading for v0.22.1 doesn't say 0.22.0.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 17:25:17 +02:00
Niklas Ye 8b2789b9b2 Let the filter chips wrap in the desktop incident list
The list pane is 340-420px wide and its chip row scrolled sideways with the
scrollbar hidden. That works by swipe on a phone, but a mouse has nothing to
grab, so Archived (the last chip) could not be reached on a wide screen. In
the desktop layout the row now wraps instead, and the divider between the
status and team chips is hidden there, since it would sit mid-line.

Phones keep the sideways scroll: the rule is inside the min-width: 900px
block.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 17:25:17 +02:00
Niklas Ye 71d7e1853a Set the chart's placeholder version to 0.22.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m41s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 56s
Release / scan-image (push) Successful in 5s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 734cd9c and 43f0044, so a
tree heading for v0.22.0 doesn't say 0.21.0.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 16:55:12 +02:00
Niklas Ye 60ebb75cd2 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
2026-09-25 15:42:25 +02:00
Niklas Ye 734cd9c5fd Set the chart's placeholder version to 0.21.0
CI / chart (push) Successful in 2s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m52s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 3s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 25s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 43f0044 and e77f04b, so a
tree heading for v0.21.0 doesn't say 0.20.1.
2026-09-25 13:02:08 +02:00
Niklas Ye 423ed9b3a3 Add a Stats page to the web UI
Statistics used to live only in terdut-tui; the account page said so.
The page shows the same figures as the TUI's Stats tab -- incident
counts, MTTA and MTTR, top alerts, and alert frequency by hour (UTC) and
by day of week -- and adds a range picker (Today, 7d, 30d, 90d, All)
that the TUI does not have. The ranges are day-granular because the
server reads from/to as whole UTC dates, so there is no 24h chip.

No server change: the page uses the existing /api/stats/* endpoints,
which already scope to the caller's teams. Charts are inline SVG and
plain elements sized from script, because the CSP forbids inline styles,
inline scripts and CDN libraries.

Removes the "statistics are in terdut-tui" notes from the account page
and the README.
2026-09-25 13:02:08 +02:00
Niklas Ye 43f004499b Set the chart's placeholder version to 0.20.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m30s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 15s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as e77f04b and 429d5fd, so a
tree heading for v0.20.1 doesn't say 0.20.0.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 22:42:13 +02:00
Niklas Ye 559be6de6e Retry the first database ping instead of dying on it
Every start crashed once or twice before going healthy: kube-router
enforces this namespace's NetworkPolicy per-node, reacting to the new
pod's creation event, and the app's first connection attempt can
reach Postgres's node before that node's allow-set has been updated
with the new pod's IP. The result is "connection refused" -- an
active reject, not a timeout, which is how it was told apart from
Postgres itself not being ready (it had been up for two days in the
run that was diagnosed).

That race resolves within several seconds in practice, so Open now
retries the ping up to five times, two seconds apart, logging each
failure, before giving up with the same wrapped error as before.
Nothing else about Open's behaviour changed: a genuinely absent
database still fails, just after ~8s instead of immediately.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 22:42:05 +02:00
22 changed files with 813 additions and 35 deletions
+3 -2
View File
@@ -51,8 +51,9 @@ archive), who is on call, the alert feed, and an *Account* tab for your own
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
it navigates through a hamburger menu and has a sticky action bar, it follows the
system's dark mode, and it can be added to the home screen. From 900px wide it switches
to a sidebar with the queue and the incident side by side. Statistics remain in
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
to a sidebar with the queue and the incident side by side. The Stats page shows
incident counts, MTTA and MTTR, and alert frequency by name, hour and day over a
chosen range.
You sign in with a username and password. Users have no password until one is
set, and a user without one can only use API keys:
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.20.0
appVersion: "v0.20.0"
version: 0.23.0
appVersion: "v0.23.0"
+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)
}
}
+26 -4
View File
@@ -5,6 +5,7 @@ import (
"embed"
"fmt"
"io/fs"
"log"
"sort"
"strings"
"time"
@@ -15,6 +16,19 @@ import (
//go:embed migrations
var migrationsFS embed.FS
// pingAttempts and pingRetryDelay bound the retry on the first connection.
// This pod's own IP can reach the Postgres pod's node before that node's
// NetworkPolicy enforcement (kube-router, reacting to the pod's creation
// event) has added it to the allowed-source set, which fails the ping with
// "connection refused" rather than a timeout. That race resolves within
// several seconds in practice; five attempts two seconds apart give it
// comfortable room without turning a genuinely absent database into a long
// hang.
const (
pingAttempts = 5
pingRetryDelay = 2 * time.Second
)
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
//
@@ -33,11 +47,19 @@ func Open(dsn string) (*sql.DB, error) {
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
for attempt := 1; ; attempt++ {
err = db.Ping()
if err == nil {
return db, nil
}
if attempt == pingAttempts {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
}
log.Printf("open db: ping attempt %d/%d failed, retrying in %s: %v", attempt, pingAttempts, pingRetryDelay, err)
time.Sleep(pingRetryDelay)
}
return db, nil
}
// Migrate applies every embedded migration that has not been applied yet, in
@@ -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"`
}
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"io/fs"
"strings"
"testing"
)
func TestCopyIncidentIsEmbedded(t *testing.T) {
sub, err := fs.Sub(files, "static")
if err != nil {
t.Fatal(err)
}
for file, want := range map[string]string{
"js/incident.js": "copyIncident",
"js/ui.js": "copy:",
} {
b, err := fs.ReadFile(sub, file)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), want) {
t.Errorf("%s lacks %s", file, want)
}
}
}
+49 -1
View File
@@ -377,6 +377,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border);
}
.detail-head .copy { margin-left: auto; }
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
@@ -450,11 +452,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); }
@@ -633,6 +640,10 @@ kbd {
.view-queue .pane { overflow: auto; height: 100dvh; }
.pane-list { border-right: 1px solid var(--border); }
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
is hidden, so the chips wrap here instead: Archived stays reachable. */
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
.pane-list .chip-sep { display: none; }
.view-queue:not(.has-detail) .pane-detail { display: block; }
/* On desktop the list stays visible next to the detail. */
@@ -867,3 +878,40 @@ button.rota-day:hover { background: var(--surface-2); }
.overview-head { display: flex; align-items: baseline; gap: 8px; }
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
.overview-item p { margin: 4px 0 0; }
/* ---------- stats page ---------- */
#view-stats .chips { padding-left: 0; padding-right: 0; }
.stats { display: grid; gap: 14px; padding-bottom: 16px; }
.stat-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.stat-tile {
--sev: var(--border-strong);
background: var(--surface); border: 1px solid var(--border);
border-left: 3px solid var(--sev); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 12px;
}
.stat-tile.st-triggered { --sev: var(--crit); }
.stat-tile.st-acknowledged { --sev: var(--warn); }
.stat-tile.st-resolved { --sev: var(--ok); }
.stat-value { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat-label { margin-top: 2px; font-size: 12px; color: var(--muted); }
.chart-card + .chart-card { margin-top: 0; }
.chart-title {
margin-bottom: 10px; font-size: 13px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
}
.hbars { list-style: none; display: grid; gap: 6px; }
.hbar { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 13px; }
.hbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hbar-track { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
.hbar-fill { display: block; height: 100%; border-radius: 5px; background: var(--ok); }
.hbar-count { min-width: 2ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
.columns { display: block; width: 100%; height: auto; }
.columns .axis { stroke: var(--border-strong); stroke-width: 1; }
.columns .col-hit { fill: transparent; }
.columns .col-bar { fill: var(--accent); }
.columns .col:hover .col-bar { opacity: 0.75; }
.columns .col-label { fill: var(--faint); font-size: 10px; font-family: var(--font); }
@media (min-width: 900px) {
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
}
+5
View File
@@ -90,6 +90,10 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
<span class="nav-label">Alerts</span>
</a>
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
<span class="nav-label">Stats</span>
</a>
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span>
@@ -128,6 +132,7 @@
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
<section id="view-stats" class="view view-page" data-view="stats" hidden></section>
<section id="view-team" class="view view-page" data-view="team" hidden></section>
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
-1
View File
@@ -35,7 +35,6 @@ function render() {
h('div', { class: 'page-head' }),
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }),
);
}
+9 -2
View File
@@ -83,15 +83,22 @@ 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
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
// alerts
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
+4 -1
View File
@@ -8,6 +8,7 @@ import * as queue from './queue.js';
import * as incident from './incident.js';
import * as oncall from './oncall.js';
import * as alerts from './alerts.js';
import * as stats from './stats.js';
import * as account from './account.js';
import * as team from './team.js';
import * as admin from './admin.js';
@@ -23,6 +24,7 @@ const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
stats: { title: 'Stats', view: stats },
team: { title: 'Team', view: team },
admin: { title: 'Admin', view: admin },
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
@@ -36,6 +38,7 @@ const NAV_ITEMS = [
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
@@ -58,7 +61,7 @@ function parseRoute(pathname) {
if (t) return { section: 'admin', tab: t.tab };
const tt = team.TABS.find((x) => x.path === `/${name}`);
if (tt) return { section: 'team', tab: tt.tab };
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
+163 -15
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;
@@ -60,6 +66,8 @@ function render() {
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
icon('back')),
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
inc && h('button', { class: 'btn btn-ghost btn-icon copy', type: 'button', 'aria-label': 'Copy incident', title: 'Copy incident (y)', onclick: copyIncident },
icon('copy')),
);
if (!inc) {
@@ -81,6 +89,7 @@ function render() {
facts(),
groupLabels(),
alertsSection(),
similarSection(),
timelineSection(),
),
actionBar(),
@@ -179,8 +188,9 @@ function alertItem(a) {
// ---------- timeline ----------
function eventText(ev) {
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
// named spells users out instead of "you", for text that leaves this page.
function eventText(ev, named = false) {
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null;
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
const alertName = () => {
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
@@ -197,6 +207,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 +220,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,20 +250,116 @@ 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')),
),
);
}
// ---------- copy ----------
const fence = (rows) => ['```', ...rows, '```'];
const pairs = (obj) => Object.entries(obj || {}).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`);
// incidentMarkdown is everything on this page as text that reads well in a chat
// or an agent prompt. Times are ISO 8601, since "3 min ago" means nothing once
// it has been pasted somewhere else.
function incidentMarkdown() {
const out = [`# Incident #${inc.id}: ${inc.title}`, ''];
const add = (k, v) => { if (v != null && v !== '') out.push(`- ${k}: ${v}`); };
add('Status', inc.status);
add('Severity', inc.severity);
add('Team', inc.team_name);
add('Assigned to', inc.assigned_to_id != null ? inc.assigned_to || 'someone' : 'unassigned');
add('Triggered', inc.triggered_at);
if (inc.acknowledged_at) add('Acknowledged', `${inc.acknowledged_at} by ${inc.acknowledged_by || 'someone'}`);
if (isOpen() && isFuture(inc.snoozed_until)) add('Snoozed until', inc.snoozed_until);
if (inc.escalation_level > 0) add('Escalation level', inc.escalation_level);
if (inc.resolved_at) add('Resolved', `${inc.resolved_at} (${inc.resolution_source === 'manual' ? 'manually' : 'all alerts stopped firing'})`);
if (inc.archived_at) add('Archived', inc.archived_at);
const group = pairs(inc.group_labels);
if (group.length) out.push('- Grouped by:', ...group.map((g) => ` - ${g}`));
const alerts = inc.alerts || [];
out.push('', `## Alerts (${alerts.length})`);
for (const a of alerts) {
out.push('', `### ${a.name} (${a.status})`);
out.push(`- Started: ${a.starts_at}`);
if (a.status === 'resolved' && a.ends_at) out.push(`- Ended: ${a.ends_at}`);
if (a.generator_url) out.push(`- Source: ${a.generator_url}`);
const labels = pairs(a.labels);
if (labels.length) out.push('', 'Labels:', ...fence(labels));
const annotations = Object.entries(a.annotations || {}).sort(([x], [y]) => x.localeCompare(y));
if (annotations.length) out.push('', 'Annotations:', ...fence(annotations.map(([k, v]) => `${k}: ${v}`)));
}
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
if (sorted.length) {
out.push('', '## Timeline', '');
for (const ev of sorted) {
const text = eventText(ev, true).map((f) => (f instanceof Node ? f.textContent : f)).join('');
out.push(`- ${ev.created_at} ${text}`);
if (isNote(ev) && ev.detail) {
const label = ev.type === 'resolution_note' ? ' (what fixed it)' : '';
out.push(...(label ? [label] : []), ...ev.detail.split('\n').map((l) => ` > ${l}`));
}
}
}
if (similarList.length) {
out.push('', '## Seen before', '', 'Earlier incidents with the same signature:');
for (const s of similarList) {
out.push(`- #${s.id} ${s.title} (resolved ${s.resolved_at})`);
for (const n of s.resolution_notes || []) {
out.push(' - What fixed it:', ...(n.detail || '').split('\n').map((l) => ` > ${l}`));
}
}
}
out.push('', `_Copied from Terminal Duty at ${new Date().toISOString()}_`, '');
return out.join('\n');
}
// writeClipboard falls back to execCommand: the async API needs a secure
// context, and this server is often reached over plain HTTP.
async function writeClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// fall through
}
const ta = h('textarea', { readonly: true, 'aria-hidden': 'true', class: 'clip-buffer' });
ta.value = text;
document.body.append(ta);
ta.select();
try {
if (!document.execCommand('copy')) throw new Error('copy refused');
} finally {
ta.remove();
}
}
async function copyIncident() {
if (!inc) return;
try {
await writeClipboard(incidentMarkdown());
toast('Copied incident');
} catch {
toast('Could not copy', 'error');
}
}
// ---------- actions ----------
const isOpen = () => inc.status !== 'resolved';
@@ -296,15 +419,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 +525,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 +547,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) {
@@ -422,10 +567,12 @@ async function moreMenu() {
items.push(item('user', 'Assign…', assign));
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
} else {
items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
}
@@ -454,6 +601,7 @@ export function key(e) {
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
case 'Z': if (isSnoozed()) unsnooze(); return true;
case 'c': addNote(); return true;
case 'y': copyIncident(); return true;
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
default: return false;
}
+168
View File
@@ -0,0 +1,168 @@
// Statistics: how many incidents, how fast they are answered, and when and
// what the alerts are. The same figures the TUI's Stats tab shows, over a
// range picked with the chips. The server scopes them to the caller's teams.
import * as api from './api.js';
import { h, clear, emptyState, spinner } from './ui.js';
import { duration } from './format.js';
const DAY_MS = 24 * 60 * 60 * 1000;
// `days` counts back from today, inclusive; the server reads from/to as UTC
// dates, so these are too.
const RANGES = [
{ id: 'today', label: 'Today', days: 1 },
{ id: '7d', label: '7d', days: 7 },
{ id: '30d', label: '30d', days: 30 },
{ id: '90d', label: '90d', days: 90 },
{ id: 'all', label: 'All', days: null },
];
const view = () => document.getElementById('view-stats');
let range = '30d';
let data = null;
let error = null;
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
function query(r) {
if (!r.days) return {};
const now = Date.now();
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
}
export function show() {
render();
refresh();
}
export async function refresh() {
const requested = range;
const q = query(RANGES.find((x) => x.id === range));
try {
const [incidents, top, byHour, byDay] = await Promise.all([
api.statsIncidents(q),
api.statsTop({ ...q, limit: 10 }),
api.statsByHour(q),
api.statsByDay(q),
]);
if (requested !== range) return;
data = { incidents, top, byHour, byDay };
error = null;
} catch (err) {
if (requested !== range) return;
error = err.message;
}
render();
}
function setRange(id) {
if (id === range) return;
range = id;
data = null;
render();
refresh();
}
function render() {
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Time range' },
RANGES.map((r) => h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(r.id === range),
onclick: () => setRange(r.id),
text: r.label,
})));
let body;
if (error && !data) body = h('div', { class: 'load-error', text: error });
else if (!data) body = spinner();
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
body = emptyState('No data in this range', '', 'chart');
} else {
body = h('div', { class: 'stats' },
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
tiles(data.incidents),
data.top.length > 0 && card('Top alerts', topAlerts(data.top)),
card('Alerts by hour (UTC)', columns(
data.byHour.map((x) => ({ label: String(x.hour), value: x.count, tick: x.hour % 6 === 0 })),
'Alerts by hour of day')),
card('Alerts by day', columns(
data.byDay.map((x) => ({ label: x.day_name.slice(0, 3), value: x.count, tick: true })),
'Alerts by day of week')));
}
clear(view(), h('div', {}, chips, body));
}
// A missing mean means nothing has been acknowledged or resolved yet.
const mean = (s) => (s == null ? '—' : duration(s * 1000));
function tiles(s) {
const tile = (label, value, cls = '') => h('div', { class: `stat-tile ${cls}` },
h('div', { class: 'stat-value', text: String(value) }),
h('div', { class: 'stat-label', text: label }));
return h('div', { class: 'stat-tiles' },
tile('Incidents', s.total),
tile('Triggered', s.triggered, 'st-triggered'),
tile('Acknowledged', s.acknowledged, 'st-acknowledged'),
tile('Resolved', s.resolved, 'st-resolved'),
tile('Mean time to acknowledge', mean(s.mtta_seconds)),
tile('Mean time to resolve', mean(s.mttr_seconds)));
}
function card(title, content) {
return h('section', { class: 'chart-card card card-pad' },
h('h3', { class: 'chart-title', text: title }), content);
}
// Ranked names with a bar scaled to the busiest one.
function topAlerts(items) {
const max = Math.max(...items.map((x) => x.count), 1);
return h('ol', { class: 'hbars' }, items.map((x) => {
const fill = h('span', { class: 'hbar-fill' });
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
return h('li', { class: 'hbar' },
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
h('span', { class: 'hbar-track' }, fill),
h('span', { class: 'hbar-count', text: String(x.count) }));
}));
}
const SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs = {}, text) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
if (text != null) el.textContent = text;
return el;
}
// A column chart: one bar per item, the value in a tooltip, and a label under
// the items marked `tick`.
function columns(items, label) {
const W = 480;
const H = 140;
const base = H - 18;
const step = W / items.length;
const max = Math.max(...items.map((x) => x.value), 1);
const root = svg('svg', {
class: 'columns', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': label,
});
root.appendChild(svg('line', { class: 'axis', x1: 0, x2: W, y1: base, y2: base }));
items.forEach((it, i) => {
const bh = it.value ? Math.max(2, (it.value / max) * (base - 6)) : 0;
const x = i * step + step * 0.15;
const g = svg('g', { class: 'col' });
g.appendChild(svg('title', {}, `${it.label}: ${it.value}`));
// A full-height transparent hit area, so a tiny bar is still hoverable.
g.appendChild(svg('rect', { class: 'col-hit', x: i * step, y: 0, width: step, height: base }));
if (bh) g.appendChild(svg('rect', { class: 'col-bar', x, y: base - bh, width: step * 0.7, height: bh, rx: 2 }));
root.appendChild(g);
if (it.tick) {
root.appendChild(svg('text', { class: 'col-label', x: i * step + step / 2, y: H - 4, 'text-anchor': 'middle' }, it.label));
}
});
return root;
}
+2
View File
@@ -43,10 +43,12 @@ const ICONS = {
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
chevronLeft: ['M15 18l-6-6 6-6'],
chevronRight: ['M9 6l6 6-6 6'],
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"io/fs"
"strings"
"testing"
)
func TestStatsPageIsEmbedded(t *testing.T) {
sub, err := fs.Sub(files, "static")
if err != nil {
t.Fatal(err)
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`id="view-stats"`, `data-section="stats"`} {
if !strings.Contains(string(index), want) {
t.Errorf("index.html lacks %s", want)
}
}
if _, err := fs.Stat(sub, "js/stats.js"); err != nil {
t.Errorf("js/stats.js not embedded: %v", err)
}
}