Compare commits

...

6 Commits

Author SHA1 Message Date
Niklas Ye df83adfe47 Set the chart's placeholder version to 0.37.2
CI / chart (push) Successful in 1s
CI / security (push) Successful in 20s
CI / test (push) Successful in 5m28s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 49s
Release / image (push) Successful in 1m17s
Release / scan-image (push) Successful in 26s
2026-10-07 21:12:51 +02:00
Niklas Ye 9da913080f Stop 500ing when a service account acts on an incident
Every incident-mutation handler read userFromContext(ctx) and wrote the
result's .ID into acknowledged_by/incident_events.user_id without checking
the ok bool. For a team-scoped service-account caller this returned a
zero-value user id, which violated the users(id) FK and 500'd on
acknowledge, unacknowledge, resolve, snooze, unsnooze and create-note.
handleDeleteNote didn't crash but silently matched zero rows instead
(WHERE user_id = 0), so a service account could never delete its own note.

Add acknowledged_by_service_account_id (incidents) and service_account_id
(incident_events) as nullable FKs to service_accounts(id), parallel to and
mutually exclusive with the existing human columns (migration 015, with a
CHECK enforcing the exclusion). Route every one of the six handlers plus
delete-note through a new callerActorIDs() helper that branches on
Caller.AsHuman()/ServiceAccountID() instead of assuming a human, and thread
a serviceAccountID parameter through logEvent and the new
acknowledgeIncidentAs (acknowledgeIncident itself is untouched: its only
other caller, the push-notification Acknowledge button, is always human).
Render the new actor distinctly from both a human and "the server acted"
in the web UI's incident timeline and facts card.

handleIncidentAssign, handleIncidentArchive and handleIncidentUnarchive are
deliberately not touched here — they track no actor at all today, for
anyone, which is a separate pre-existing gap (follow-up issue to come).

Fixes #25
2026-10-07 21:09:31 +02:00
Niklas Ye 2b2609e98f Set the chart's placeholder version to 0.37.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 1m18s
CI / test (push) Successful in 6m25s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 7s
Release / image (push) Successful in 2m22s
Release / scan-image (push) Successful in 26s
Release / binaries (push) Successful in 2m53s
Cosmetic: `make helm-package` passes --version and --app-version from
the tag, so these fields decide nothing about what gets published. But
a tree heading for v0.37.1 that still says 0.37.0 tells a reader
something false. Same as 7efd1bb, which cites 5c4e0bd.
2026-10-04 14:26:31 +02:00
Niklas Ye fa6d82d6e5 Fix desktop layout bugs and show more incident actions directly
On-call, the incident queue and the account page all had latent CSS bugs
that only show up once the browser is wide enough to hit the desktop
breakpoint (900px+):

- On-call: .days reset its own margin to 0, which canceled the page-wide
  auto-centering on just that element, leaving the day list pinned to the
  left edge while every other card on the page centered normally.
- Queue: the list pane stayed capped at 340-420px even with nothing
  selected, leaving the rest of the screen empty. It now fills the width
  until an incident is picked, then goes back to list+detail.
- Account, admin user and admin team: .btn and .back-link are inline-flex,
  and margin:auto only centers a block box, so the Sign-out button and the
  two admin back-links sat left of their sibling cards instead of matching
  their width. Wrapped each in a block div.

The incident detail action bar also folded Assign, Add note, Resolve and
Clear acknowledgement into a "More" sheet sized for a phone's width.
Desktop has the room, so it now shows them as direct buttons and hides
More instead; Copy incident stays out of the bar since the header already
has its own button for it.

Filed as niklas/terdut-server#31, #32, #33, #34, each with a screenshot.
2026-10-04 14:26:16 +02:00
Niklas Ye 7efd1bbba7 Set the chart's placeholder version to 0.37.0
CI / chart (push) Successful in 1s
CI / test (push) Successful in 7s
CI / security (push) Successful in 12s
Release / test (push) Successful in 6s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 1s
2026-10-03 16:17:20 +02:00
Niklas Ye 3bf94a5d7f Default to 2 replicas and RollingUpdate now that the singleton jobs are locked
CI / chart (push) Successful in 0s
CI / test (push) Successful in 7s
CI / security (push) Successful in 12s
replicas and strategy: Recreate were the chart's only guard against the
archiver, notifier and migration races; v0.36.0 closed all three with
advisory locks and a conflict-resolving incident insert, which made
that guard redundant rather than load-bearing. Expose replicaCount
(new, no values.yaml key existed before) defaulting to 2, and switch
to strategy: RollingUpdate with no explicit maxUnavailable/maxSurge --
the 25%/25% default rounds to 0/1 at replicaCount: 2, which is already
zero-downtime.

The chart does not gate this on image.tag, so pointing it at a
pre-v0.36.0 image with the new default is a foot-gun by omission --
noted in both the values.yaml comment and the deployment.yaml comment,
not guarded in code, same as the chart does for every other
version-coupled assumption today.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 12:30:27 +02:00
19 changed files with 364 additions and 91 deletions
+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: # 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 # image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing. # metadata and drives nothing.
version: 0.36.0 version: 0.37.2
appVersion: "v0.36.0" appVersion: "v0.37.2"
+9 -10
View File
@@ -6,20 +6,19 @@ metadata:
labels: labels:
{{- include "terdut-server.labels" . | nindent 4 }} {{- include "terdut-server.labels" . | nindent 4 }}
spec: spec:
replicas: 1 replicas: {{ .Values.replicaCount }}
selector: selector:
matchLabels: matchLabels:
{{- include "terdut-server.selectorLabels" . | nindent 6 }} {{- include "terdut-server.selectorLabels" . | nindent 6 }}
# Recreate, not RollingUpdate, even though the PVC that forced it is gone: # RollingUpdate, not Recreate: the sweeper, notifier and migration runner
# the sweeper, notifier and migration runner take a Postgres advisory lock # each take a Postgres advisory lock around their own pass, and new-incident
# each, and new-incident creation on the first webhook for a brand-new # creation on the first webhook for a brand-new groupKey resolves its own
# groupKey resolves its own insert conflict — so two replicas overlapping # insert conflict -- so two replicas overlapping during a rollout no longer
# during a rollout no longer double-page, race a migration, or drop a # double-page, race a migration, or drop a webhook payload (v0.36.0). No
# webhook payload. Nothing left here actually requires Recreate anymore; # explicit maxUnavailable/maxSurge: the 25%/25% default rounds to 0/1 at
# it stays the default pending a deliberate decision to raise replicas # replicaCount: 2, which is zero-downtime already.
# above 1 and move to RollingUpdate.
strategy: strategy:
type: Recreate type: RollingUpdate
template: template:
metadata: metadata:
labels: labels:
+7
View File
@@ -1,3 +1,10 @@
# Safe above 1 since v0.36.0: the sweeper, notifier and migration runner each
# take a Postgres advisory lock around their own pass, and a webhook that
# loses the race to open a brand-new incident attaches to the winner's row
# instead of dropping its payload. An image older than v0.36.0 does not have
# these guards -- do not raise this against one.
replicaCount: 2
networking: networking:
hostname: "terdut.example.com" hostname: "terdut.example.com"
servicePort: 8080 servicePort: 8080
+4 -4
View File
@@ -169,7 +169,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, src alertSourc
} }
touched[id] = true touched[id] = true
alertID := a.id alertID := a.id
if err := logEvent(ctx, tx, id, evAlertResolved, nil, &alertID, nil); err != nil { if err := logEvent(ctx, tx, id, evAlertResolved, nil, nil, &alertID, nil); err != nil {
return err return err
} }
} }
@@ -417,12 +417,12 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
return 0, err return 0, err
} }
if err := logEvent(ctx, q, id, evTriggered, nil, nil, nil); err != nil { if err := logEvent(ctx, q, id, evTriggered, nil, nil, nil, nil); err != nil {
return 0, err return 0, err
} }
if onCall != nil { if onCall != nil {
// On an "assigned" event user_id is the assignee, not the actor. // On an "assigned" event user_id is the assignee, not the actor.
if err := logEvent(ctx, q, id, evAssigned, onCall, nil, nil); err != nil { if err := logEvent(ctx, q, id, evAssigned, onCall, nil, nil, nil); err != nil {
return 0, err return 0, err
} }
} }
@@ -470,5 +470,5 @@ func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
return nil return nil
} }
return logEvent(ctx, tx, incidentID, evAlertAdded, nil, &alertID, nil) return logEvent(ctx, tx, incidentID, evAlertAdded, nil, nil, &alertID, nil)
} }
+1 -1
View File
@@ -142,7 +142,7 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
continue continue
} }
alertID := id alertID := id
if err := logEvent(ctx, db, incidentID, evAlertResolved, nil, &alertID, nil); err != nil { if err := logEvent(ctx, db, incidentID, evAlertResolved, nil, nil, &alertID, nil); err != nil {
log.Printf("sweeper: log expiry event: %v", err) log.Printf("sweeper: log expiry event: %v", err)
} }
} }
+16 -5
View File
@@ -99,13 +99,24 @@ func (c Caller) ServiceAccountID() (int64, bool) {
return c.sa.id, true return c.sa.id, true
} }
// ServiceAccountName reports this caller's own service-account name, for a
// handler's synchronous response — the same credential it authenticated
// with, already resolved onto the Caller by serveAsServiceAccount, so no
// extra query is needed.
func (c Caller) ServiceAccountName() (string, bool) {
if c.sa == nil {
return "", false
}
return c.sa.name, true
}
// Identity is a stable, log/audit-facing string distinguishing a human // Identity is a stable, log/audit-facing string distinguishing a human
// caller from a service account — "user:42" or "service-account:7". Not // caller from a service account — "user:42" or "service-account:7". Not
// wired into any database column today (incidents.go's acknowledged_by/ // wired into any database column — incidents.go's acknowledged_by/
// assigned_to/user_id are explicitly out of scope for this change — that // incident_events.user_id use AsHuman()/ServiceAccountID() directly against
// needs its own schema migration, tracked separately), but this is the one // the parallel *_service_account_id columns (migration 015) instead, since a
// place in the request path that already knows which kind of caller this // column needs the id, not this rendered string. assigned_to stays
// is, and that follow-up will want exactly this accessor. // human-only and out of scope (terdut-server#25's follow-up).
func (c Caller) Identity() string { func (c Caller) Identity() string {
switch { switch {
case c.user != nil: case c.user != nil:
+2 -2
View File
@@ -373,7 +373,7 @@ func deadmanDied(ctx context.Context, db *sql.DB, notify NotifyConfig, hb deadma
alertID := hb.id alertID := hb.id
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago" detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago"
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil { if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, nil, &alertID, &detail); err != nil {
return err return err
} }
@@ -415,7 +415,7 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, hb deadmanAlert) error {
time.Now().Unix(), incidentResolutionRecovered, incidentID); err != nil { time.Now().Unix(), incidentResolutionRecovered, incidentID); err != nil {
return err return err
} }
if err := logEvent(ctx, tx, incidentID, evResolved, nil, nil, nil); err != nil { if err := logEvent(ctx, tx, incidentID, evResolved, nil, nil, nil, nil); err != nil {
return err return err
} }
// The all-clear goes to whoever was paged, which enqueueResolved works out // The all-clear goes to whoever was paged, which enqueueResolved works out
+2 -2
View File
@@ -229,7 +229,7 @@ func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy
// nobody. That is a policy that looks configured and is not. // nobody. That is a policy that looks configured and is not.
detail += ": nobody reachable" detail += ": nobody reachable"
} }
if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail); err != nil { if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, nil, &detail); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()
@@ -256,7 +256,7 @@ func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPoli
incidentID); err != nil { incidentID); err != nil {
return err return err
} }
return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail) return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, nil, &detail)
} }
// pageLevel notifies every target of one level and reports who was woken. // pageLevel notifies every target of one level and reports who was woken.
+50 -18
View File
@@ -65,11 +65,13 @@ const incidentSelectFrom = `
WHERE el.team_id = i.team_id AND el.position = i.escalation_level), WHERE el.team_id = i.team_id AND el.position = i.escalation_level),
i.triggered_at, i.triggered_at,
i.acknowledged_by, i.acknowledged_at, ack.username, i.acknowledged_by, i.acknowledged_at, ack.username,
i.acknowledged_by_service_account_id, acksa.name,
i.assigned_to, asg.username, i.snoozed_until, i.assigned_to, asg.username, i.snoozed_until,
i.resolved_at, i.resolution_source, i.archived_at i.resolved_at, i.resolution_source, i.archived_at
FROM incidents i FROM incidents i
JOIN teams t ON t.id = i.team_id JOIN teams t ON t.id = i.team_id
LEFT JOIN users ack ON ack.id = i.acknowledged_by LEFT JOIN users ack ON ack.id = i.acknowledged_by
LEFT JOIN service_accounts acksa ON acksa.id = i.acknowledged_by_service_account_id
LEFT JOIN users asg ON asg.id = i.assigned_to` LEFT JOIN users asg ON asg.id = i.assigned_to`
func scanIncident(s scanner) (models.Incident, error) { func scanIncident(s scanner) (models.Incident, error) {
@@ -83,6 +85,7 @@ func scanIncident(s scanner) (models.Incident, error) {
&i.EscalationLevel, &escalationDue, &i.EscalationLevel, &escalationDue,
&triggeredAt, &triggeredAt,
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser, &i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
&i.AcknowledgedByServiceAccountID, &i.AcknowledgedByServiceAccountName,
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil, &i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
&resolvedAt, &i.ResolutionSource, &archivedAt, &resolvedAt, &i.ResolutionSource, &archivedAt,
); err != nil { ); err != nil {
@@ -112,13 +115,31 @@ func fetchIncident(ctx context.Context, q querier, id int64) (models.Incident, e
return scanIncident(q.QueryRowContext(ctx, incidentSelectFrom+" WHERE i.id = $1", id)) return scanIncident(q.QueryRowContext(ctx, incidentSelectFrom+" WHERE i.id = $1", id))
} }
// logEvent appends one entry to an incident's timeline. A nil userID means the // callerActorIDs resolves the current request's caller into the pair of
// server acted rather than a person. // nilable ids logEvent/acknowledgeIncidentAs expect: exactly one of userID/
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, alertID *int64, detail *string) error { // serviceAccountID is set (never both), replacing the unchecked
// userFromContext(ctx) zero-value reads that used to write a human-only id
// of 0 for a service-account caller (terdut-server#25).
func callerActorIDs(ctx context.Context) (userID, serviceAccountID *int64) {
caller, _ := callerFromContext(ctx)
if u, ok := caller.AsHuman(); ok {
return &u.ID, nil
}
if id, ok := caller.ServiceAccountID(); ok {
return nil, &id
}
return nil, nil
}
// logEvent appends one entry to an incident's timeline. userID and
// serviceAccountID are mutually exclusive and both nilable; both nil means
// the server acted rather than any caller (see incident_events_actor_xor_chk,
// migration 015).
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, serviceAccountID, alertID *int64, detail *string) error {
_, err := q.ExecContext(ctx, ` _, err := q.ExecContext(ctx, `
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at) INSERT INTO incident_events (incident_id, type, user_id, service_account_id, alert_id, detail, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`, VALUES ($1, $2, $3, $4, $5, $6, $7)`,
incidentID, evType, userID, alertID, detail, time.Now().Unix()) incidentID, evType, userID, serviceAccountID, alertID, detail, time.Now().Unix())
return err return err
} }
@@ -251,7 +272,7 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
if err := stopEscalation(ctx, q, incidentID); err != nil { if err := stopEscalation(ctx, q, incidentID); err != nil {
return false, err return false, err
} }
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil); err != nil { if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil, nil); err != nil {
return false, err return false, err
} }
// The all-clear goes only to whoever was paged in the first place, which // The all-clear goes only to whoever was paged in the first place, which
@@ -260,19 +281,30 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
return true, enqueueResolved(ctx, q, incidentID) return true, enqueueResolved(ctx, q, incidentID)
} }
// acknowledgeIncident records that userID has picked an incident up, and reports // acknowledgeIncident records that userID — a human — has picked an incident
// whether it changed anything — an already-resolved or already-acknowledged // up, and reports whether it changed anything — an already-resolved or
// incident is left alone, so a second acknowledge (a retried request, or a // already-acknowledged incident is left alone, so a second acknowledge (a
// stale push notification tapped after the web UI already acked it) is a // retried request, or a stale push notification tapped after the web UI
// no-op rather than a second "acknowledged" timeline entry. Shared by the // already acked it) is a no-op rather than a second "acknowledged" timeline
// authenticated handler and the Acknowledge button in a push notification, // entry. Used only by the Acknowledge button in a push notification
// so both write the same state and the same timeline entry. // (notify_ack.go), which always resolves a human from
// incident_ack_tokens.user_id — there is no service-account equivalent of
// that flow, so this keeps its human-only signature; the authenticated
// handler goes through acknowledgeIncidentAs below instead.
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) { func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
return acknowledgeIncidentAs(ctx, q, incidentID, &userID, nil)
}
// acknowledgeIncidentAs is acknowledgeIncident generalized to either actor
// kind. userID and serviceAccountID are mutually exclusive and nilable the
// same way logEvent's are (see incidents_ack_actor_xor_chk, migration 015).
func acknowledgeIncidentAs(ctx context.Context, q querier, incidentID int64, userID, serviceAccountID *int64) (bool, error) {
res, err := q.ExecContext(ctx, ` res, err := q.ExecContext(ctx, `
UPDATE incidents UPDATE incidents
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2 SET status = 'acknowledged', acknowledged_by = $1, acknowledged_by_service_account_id = $2,
WHERE id = $3 AND status = 'triggered'`, acknowledged_at = $3
userID, time.Now().Unix(), incidentID) WHERE id = $4 AND status = 'triggered'`,
userID, serviceAccountID, time.Now().Unix(), incidentID)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -283,7 +315,7 @@ func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int6
if err := stopEscalation(ctx, q, incidentID); err != nil { if err := stopEscalation(ctx, q, incidentID); err != nil {
return false, err return false, err
} }
return true, logEvent(ctx, q, incidentID, evAcknowledged, &userID, nil, nil) return true, logEvent(ctx, q, incidentID, evAcknowledged, userID, serviceAccountID, nil, nil)
} }
// openIncidentForAlert returns the open incident an alert currently belongs to, // openIncidentForAlert returns the open incident an alert currently belongs to,
+34 -24
View File
@@ -157,9 +157,11 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
rows, err := db.QueryContext(r.Context(), ` rows, err := db.QueryContext(r.Context(), `
SELECT e.id, e.incident_id, e.type, e.user_id, u.username, SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
e.service_account_id, sa.name,
e.alert_id, e.detail, e.created_at e.alert_id, e.detail, e.created_at
FROM incident_events e FROM incident_events e
LEFT JOIN users u ON u.id = e.user_id LEFT JOIN users u ON u.id = e.user_id
LEFT JOIN service_accounts sa ON sa.id = e.service_account_id
WHERE e.incident_id = $1 WHERE e.incident_id = $1
ORDER BY e.created_at ASC, e.id ASC`, id) ORDER BY e.created_at ASC, e.id ASC`, id)
if err != nil { if err != nil {
@@ -173,6 +175,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
var e models.IncidentEvent var e models.IncidentEvent
var ts int64 var ts int64
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
&e.ServiceAccountID, &e.ServiceAccountName,
&e.AlertID, &e.Detail, &ts); err != nil { &e.AlertID, &e.Detail, &ts); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
@@ -190,8 +193,8 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
if !ok { if !ok {
return return
} }
user, _ := userFromContext(r.Context()) userID, saID := callerActorIDs(r.Context())
acked, err := acknowledgeIncident(r.Context(), db, id, user.ID) acked, err := acknowledgeIncidentAs(r.Context(), db, id, userID, saID)
if err != nil { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
@@ -223,13 +226,14 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
if !ok { if !ok {
return return
} }
user, _ := userFromContext(r.Context()) userID, saID := callerActorIDs(r.Context())
if !updateOpenIncident(w, r, db, id, if !updateOpenIncident(w, r, db, id,
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL, acknowledged_at = NULL `UPDATE incidents SET status = 'triggered', acknowledged_by = NULL,
acknowledged_by_service_account_id = NULL, acknowledged_at = NULL
WHERE id = $1 AND resolved_at IS NULL`, id) { WHERE id = $1 AND resolved_at IS NULL`, id) {
return return
} }
if err := logEvent(r.Context(), db, id, evUnacknowledged, &user.ID, nil, nil); err != nil { if err := logEvent(r.Context(), db, id, evUnacknowledged, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -247,7 +251,7 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
if !ok { if !ok {
return return
} }
user, _ := userFromContext(r.Context()) userID, saID := callerActorIDs(r.Context())
// The body is optional: clients that predate resolution notes send none. // The body is optional: clients that predate resolution notes send none.
var req struct { var req struct {
Resolution string `json:"resolution"` Resolution string `json:"resolution"`
@@ -268,12 +272,12 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
if err := logEvent(r.Context(), db, id, evResolved, &user.ID, nil, nil); err != nil { if err := logEvent(r.Context(), db, id, evResolved, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
if req.Resolution != "" { if req.Resolution != "" {
if err := logEvent(r.Context(), db, id, evResolutionNote, &user.ID, nil, &req.Resolution); err != nil { if err := logEvent(r.Context(), db, id, evResolutionNote, userID, saID, nil, &req.Resolution); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -312,7 +316,7 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
return return
} }
// On an "assigned" event user_id is the assignee, not the actor. // On an "assigned" event user_id is the assignee, not the actor.
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil); err != nil { if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -363,14 +367,14 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
return return
} }
user, _ := userFromContext(r.Context()) userID, saID := callerActorIDs(r.Context())
if !updateOpenIncident(w, r, db, id, if !updateOpenIncident(w, r, db, id,
"UPDATE incidents SET snoozed_until = $1 WHERE id = $2 AND resolved_at IS NULL", "UPDATE incidents SET snoozed_until = $1 WHERE id = $2 AND resolved_at IS NULL",
until.Unix(), id) { until.Unix(), id) {
return return
} }
detail := until.UTC().Format(time.RFC3339) detail := until.UTC().Format(time.RFC3339)
if err := logEvent(r.Context(), db, id, evSnoozed, &user.ID, nil, &detail); err != nil { if err := logEvent(r.Context(), db, id, evSnoozed, userID, saID, nil, &detail); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -384,12 +388,12 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
if !ok { if !ok {
return return
} }
user, _ := userFromContext(r.Context()) userID, saID := callerActorIDs(r.Context())
if !updateOpenIncident(w, r, db, id, if !updateOpenIncident(w, r, db, id,
"UPDATE incidents SET snoozed_until = NULL WHERE id = $1 AND resolved_at IS NULL", id) { "UPDATE incidents SET snoozed_until = NULL WHERE id = $1 AND resolved_at IS NULL", id) {
return return
} }
if err := logEvent(r.Context(), db, id, evUnsnoozed, &user.ID, nil, nil); err != nil { if err := logEvent(r.Context(), db, id, evUnsnoozed, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -466,27 +470,32 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
return return
} }
user, _ := userFromContext(r.Context()) caller, _ := callerFromContext(r.Context())
userID, saID := callerActorIDs(r.Context())
now := time.Now() now := time.Now()
var eventID int64 var eventID int64
err := db.QueryRowContext(r.Context(), ` err := db.QueryRowContext(r.Context(), `
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at) INSERT INTO incident_events (incident_id, type, user_id, service_account_id, detail, created_at)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`, id, noteType, user.ID, req.Content, now.Unix()).Scan(&eventID) RETURNING id`, id, noteType, userID, saID, req.Content, now.Unix()).Scan(&eventID)
if err != nil { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
respond(w, http.StatusCreated, models.IncidentEvent{ resp := models.IncidentEvent{
ID: eventID, ID: eventID,
IncidentID: id, IncidentID: id,
Type: noteType, Type: noteType,
UserID: &user.ID,
Username: &user.Username,
Detail: &req.Content, Detail: &req.Content,
CreatedAt: now.UTC().Truncate(time.Second), CreatedAt: now.UTC().Truncate(time.Second),
}) }
if u, ok := caller.AsHuman(); ok {
resp.UserID, resp.Username = &u.ID, &u.Username
} else if saName, ok := caller.ServiceAccountName(); ok {
resp.ServiceAccountID, resp.ServiceAccountName = saID, &saName
}
respond(w, http.StatusCreated, resp)
} }
} }
@@ -504,11 +513,12 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
return return
} }
user, _ := userFromContext(r.Context()) userID, saID := callerActorIDs(r.Context())
res, err := db.ExecContext(r.Context(), ` res, err := db.ExecContext(r.Context(), `
DELETE FROM incident_events DELETE FROM incident_events
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4) AND user_id = $5`, WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4)
eventID, id, evNote, evResolutionNote, user.ID) AND (user_id = $5 OR service_account_id = $6)`,
eventID, id, evNote, evResolutionNote, userID, saID)
if err != nil { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
+101
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"git.ryuvia.com/niklas/terdut-server/internal/api" "git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/models"
) )
// amAlert builds one alert of a webhook payload. // amAlert builds one alert of a webhook payload.
@@ -642,6 +643,106 @@ func TestIncident_ArchiveRoundTrip(t *testing.T) {
} }
} }
// ---------------------------------------------------------------------------
// Service accounts (terdut-server#25)
// ---------------------------------------------------------------------------
// TestServiceAccount_CanActOnItsTeamsIncidents is #25's regression test.
// Before the fix: acknowledge/resolve/snooze/create-note each 500'd (writing
// acknowledged_by/user_id = 0, violating the users(id) FK for a service
// account), and delete-note silently matched zero rows (WHERE user_id = 0)
// instead of deleting.
func TestServiceAccount_CanActOnItsTeamsIncidents(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
var integration struct {
Key string `json:"key"`
}
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
map[string]string{"name": "test"}), &integration)
postToIntegration(t, s, integration.Key, "fp-sa", "SAIncident") // incident 1
// Acknowledge.
resp := s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/acknowledge", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("service account acknowledge: %d", resp.StatusCode)
}
var inc map[string]any
decode(t, resp, &inc)
if inc["acknowledged_by_service_account_id"] == nil {
t.Error("expected acknowledged_by_service_account_id to be set")
}
if inc["acknowledged_by_id"] != nil {
t.Errorf("expected acknowledged_by_id to stay nil for a service-account actor, got %v", inc["acknowledged_by_id"])
}
// Unacknowledge.
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/acknowledge", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("service account unacknowledge: %d", resp.StatusCode)
}
// Snooze, then unsnooze.
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/snooze",
map[string]string{"duration": "1h"})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("service account snooze: %d", resp.StatusCode)
}
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/snooze", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("service account unsnooze: %d", resp.StatusCode)
}
// Create, then delete, a note.
var note map[string]any
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/notes",
map[string]string{"content": "looking into it"}), &note)
if note["service_account_id"] == nil {
t.Error("expected service_account_id on the note event")
}
if note["user_id"] != nil {
t.Errorf("expected no user_id on a service-account note, got %v", note["user_id"])
}
noteID := int(note["id"].(float64))
delResp := s.reqAs(t, keyA, http.MethodDelete, fmt.Sprintf("/api/incidents/1/notes/%d", noteID), nil)
delResp.Body.Close()
if delResp.StatusCode != http.StatusNoContent {
t.Errorf("service account deleting its own note: %d", delResp.StatusCode)
}
// Resolve.
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/resolve", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("service account resolve: %d", resp.StatusCode)
}
}
// Regression guard: a human actor must still write only the human columns,
// unaffected by the service-account branch added above.
func TestIncident_AcknowledgeStillWritesOnlyHumanColumn(t *testing.T) {
s := newTS(t)
postWebhook(t, s, []map[string]any{
amAlert("fp-human-ack", "Z", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
var inc map[string]any
decode(t, s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil), &inc)
if inc["acknowledged_by_id"] == nil {
t.Error("expected acknowledged_by_id to be set for a human actor")
}
if inc["acknowledged_by_service_account_id"] != nil {
t.Errorf("expected acknowledged_by_service_account_id to stay nil for a human actor, got %v",
inc["acknowledged_by_service_account_id"])
}
}
func TestSweeper_ArchivesResolvedIncidents(t *testing.T) { func TestSweeper_ArchivesResolvedIncidents(t *testing.T) {
s := newTS(t) s := newTS(t)
postWebhook(t, s, []map[string]any{ postWebhook(t, s, []map[string]any{
+2 -2
View File
@@ -257,7 +257,7 @@ func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
} }
// Logged, not returned: the page has already gone out, and treating a // Logged, not returned: the page has already gone out, and treating a
// failed timeline write as a failed delivery would send it again. // failed timeline write as a failed delivery would send it again.
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, &n.kind); err != nil { if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, nil, &n.kind); err != nil {
log.Printf("notifier: log delivery of %d: %v", n.id, err) log.Printf("notifier: log delivery of %d: %v", n.id, err)
} }
sent++ sent++
@@ -312,7 +312,7 @@ func markFailed(ctx context.Context, db *sql.DB, n outboxRow, cause error) {
return return
} }
detail := fmt.Sprintf("%s: %s", n.kind, cause) detail := fmt.Sprintf("%s: %s", n.kind, cause)
if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, &detail); err != nil { if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, nil, &detail); err != nil {
log.Printf("notifier: log failure of %d: %v", n.id, err) log.Printf("notifier: log failure of %d: %v", n.id, err)
} }
} }
@@ -0,0 +1,39 @@
-- Service-account actors on incident mutations (terdut-server#25). A
-- team-scoped service account acknowledging/resolving/snoozing/noting an
-- incident is not a users row, so it cannot be written into
-- acknowledged_by/incident_events.user_id — doing so either violates the
-- users(id) FK (new rows) or, for incident_events.user_id, silently matches
-- zero rows on delete. These columns are the service-account-shaped parallel
-- to the existing human ones: nullable, mutually exclusive with their human
-- counterpart, ON DELETE SET NULL so a deleted service account doesn't take
-- the incident history with it.
ALTER TABLE incidents
ADD COLUMN acknowledged_by_service_account_id BIGINT
REFERENCES service_accounts(id) ON DELETE SET NULL;
ALTER TABLE incident_events
ADD COLUMN service_account_id BIGINT
REFERENCES service_accounts(id) ON DELETE SET NULL;
-- At most one actor kind per row: both NULL ("the server acted") is valid,
-- exactly one set is valid, both set is a bug this constraint refuses to
-- store rather than silently accepting.
ALTER TABLE incidents
ADD CONSTRAINT incidents_ack_actor_xor_chk CHECK (
acknowledged_by IS NULL OR acknowledged_by_service_account_id IS NULL
);
ALTER TABLE incident_events
ADD CONSTRAINT incident_events_actor_xor_chk CHECK (
user_id IS NULL OR service_account_id IS NULL
);
CREATE INDEX incidents_acknowledged_by_service_account_id_idx
ON incidents(acknowledged_by_service_account_id);
CREATE INDEX incident_events_service_account_id_idx
ON incident_events(service_account_id);
-- assigned_to_service_account_id is deliberately not added here: it would sit
-- unpopulated until handleIncidentAssign itself tracks an actor, which is a
-- separate, pre-existing gap (it records the assignee today, never the
-- actor, for humans either) tracked in its own follow-up issue.
+27 -10
View File
@@ -43,6 +43,13 @@ type Incident struct {
AcknowledgedByUser *string `json:"acknowledged_by,omitempty"` AcknowledgedByUser *string `json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
// AcknowledgedByServiceAccountID/Name are the service-account-shaped
// parallel to AcknowledgedByID/User above: mutually exclusive with it,
// populated when a service account (not a human) acknowledged this
// incident. See migration 015 and terdut-server#25.
AcknowledgedByServiceAccountID *int64 `json:"acknowledged_by_service_account_id,omitempty"`
AcknowledgedByServiceAccountName *string `json:"acknowledged_by_service_account,omitempty"`
AssignedToID *int64 `json:"assigned_to_id,omitempty"` AssignedToID *int64 `json:"assigned_to_id,omitempty"`
AssignedToUser *string `json:"assigned_to,omitempty"` AssignedToUser *string `json:"assigned_to,omitempty"`
@@ -67,17 +74,27 @@ type Incident struct {
// and is the only history this server keeps — alert rows are mutated in place. // and is the only history this server keeps — alert rows are mutated in place.
// //
// Type is one of: triggered, alert_added, alert_resolved, acknowledged, // Type is one of: triggered, alert_added, alert_resolved, acknowledged,
// unacknowledged, assigned, snoozed, unsnoozed, resolved, note. A nil UserID // unacknowledged, assigned, snoozed, unsnoozed, resolved, note. UserID and
// means the server acted rather than a person. // ServiceAccountID are mutually exclusive; both nil means the server acted
// rather than any caller.
type IncidentEvent struct { type IncidentEvent struct {
ID int64 `json:"id"` ID int64 `json:"id"`
IncidentID int64 `json:"incident_id"` IncidentID int64 `json:"incident_id"`
Type string `json:"type"` Type string `json:"type"`
UserID *int64 `json:"user_id,omitempty"` UserID *int64 `json:"user_id,omitempty"`
Username *string `json:"username,omitempty"` Username *string `json:"username,omitempty"`
AlertID *int64 `json:"alert_id,omitempty"`
Detail *string `json:"detail,omitempty"` // ServiceAccountID/Name are the service-account-shaped parallel to
CreatedAt time.Time `json:"created_at"` // UserID/Username above: mutually exclusive with it, populated when a
// service account (not a human, and not nil-meaning-the-server-acted)
// performed this event. Named Name, not Username — a ServiceAccount has
// a Name field, not a Username. See migration 015 and terdut-server#25.
ServiceAccountID *int64 `json:"service_account_id,omitempty"`
ServiceAccountName *string `json:"service_account_name,omitempty"`
AlertID *int64 `json:"alert_id,omitempty"`
Detail *string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
} }
// SimilarIncident is an earlier, resolved incident with the same signature as // SimilarIncident is an earlier, resolved incident with the same signature as
+18 -2
View File
@@ -583,6 +583,10 @@ details[open] > summary { margin-bottom: 8px; }
} }
.actionbar .btn { min-height: 48px; } .actionbar .btn { min-height: 48px; }
.actionbar .btn-primary { flex: 1; font-size: 16px; } .actionbar .btn-primary { flex: 1; font-size: 16px; }
/* The extra actions that only phones hide behind "More" — see actionBar() and
extraActions() in incident.js. Hidden by default; the desktop block below
shows them and hides the now-redundant More button instead. */
.action-extra { display: none; }
.detail-placeholder { .detail-placeholder {
display: grid; place-items: center; height: 100%; display: grid; place-items: center; height: 100%;
@@ -669,7 +673,7 @@ details[open] > summary { margin-bottom: 8px; }
caught by the unrelated .label > span styling meant for label chips. */ caught by the unrelated .label > span styling meant for label chips. */
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; } .week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; } .week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
.days { list-style: none; margin: 0; padding: 0; } .days { list-style: none; margin: 0 auto; padding: 0; }
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; } .day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
.day + .day { border-top: 1px solid var(--border); } .day + .day { border-top: 1px solid var(--border); }
.day-name { font-weight: 650; } .day-name { font-weight: 650; }
@@ -773,7 +777,15 @@ kbd {
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; } .pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
.pane-list .chip-sep { display: none; } .pane-list .chip-sep { display: none; }
.chips-fade { display: none; } .chips-fade { display: none; }
.view-queue:not(.has-detail) .pane-detail { display: block; } /* With nothing selected there is no detail to show next to, so the list
takes the whole row instead of leaving the second column as dead space
around the placeholder text. Selecting an incident (.has-detail) drops
back to the base minmax(340,420) 1fr rule above. */
.view-queue:not(.has-detail) { grid-template-columns: 1fr; }
.view-queue:not(.has-detail) .pane-list { border-right: 0; }
/* Full width reads better capped than edge-to-edge on a very wide monitor,
matching .detail's own cap below. */
.view-queue:not(.has-detail) .list { max-width: 900px; margin: 0 auto; }
/* On desktop the list stays visible next to the detail. */ /* On desktop the list stays visible next to the detail. */
.app.detail-open .nav { display: flex; } .app.detail-open .nav { display: flex; }
@@ -787,8 +799,12 @@ kbd {
.actionbar { .actionbar {
position: sticky; bottom: 0; position: sticky; bottom: 0;
padding: 12px 32px; padding: 12px 32px;
flex-wrap: wrap;
} }
.actionbar .btn-primary { flex: 0 1 240px; } .actionbar .btn-primary { flex: 0 1 240px; }
/* Room enough to show every action, so More is fully redundant here. */
.action-extra { display: inline-flex; }
.more-btn { display: none; }
.sheet { .sheet {
width: min(440px, calc(100% - 32px)); width: min(440px, calc(100% - 32px));
+3 -1
View File
@@ -33,7 +33,9 @@ function render() {
h('div', { class: 'card' }, shortcuts())), h('div', { class: 'card' }, shortcuts())),
h('div', { class: 'page-head' }), h('div', { class: 'page-head' }),
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'), // Wrapped in a div: .btn is inline-flex, and only a block-level element
// picks up .view-page > *'s margin:auto centering (see app.css:316).
h('div', {}, h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out')),
); );
} }
+3 -1
View File
@@ -96,7 +96,9 @@ function render() {
} }
function backLink() { function backLink() {
return h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' })); // Wrapped in a div: .back-link is inline-flex, and only a block-level
// element picks up .view-page > *'s margin:auto centering (app.css:316).
return h('div', {}, h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' })));
} }
// --- identity -------------------------------------------------------------- // --- identity --------------------------------------------------------------
+3 -1
View File
@@ -83,7 +83,9 @@ function render() {
} }
function backLink() { function backLink() {
return h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' })); // Wrapped in a div: .back-link is inline-flex, and only a block-level
// element picks up .view-page > *'s margin:auto centering (app.css:316).
return h('div', {}, h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' })));
} }
// --- identity -------------------------------------------------------------- // --- identity --------------------------------------------------------------
+41 -6
View File
@@ -123,6 +123,16 @@ function who(id, name) {
return name || 'someone'; return name || 'someone';
} }
// ackActorLabel renders whoever acknowledged inc, human or service account —
// the two are mutually exclusive (migration 015), and a service account is a
// credential, not "you" or "nobody", so it gets its own branch rather than
// going through who()'s id-vs-myID() check.
function ackActorLabel() {
if (inc.acknowledged_by_id != null) return who(inc.acknowledged_by_id, inc.acknowledged_by);
if (inc.acknowledged_by_service_account_id != null) return inc.acknowledged_by_service_account || 'a service account';
return null;
}
function facts() { function facts() {
const rows = []; const rows = [];
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v)); const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
@@ -132,8 +142,7 @@ function facts() {
// take reading top to bottom to piece together. // take reading top to bottom to piece together.
const elapsedTo = inc.resolved_at ? Date.parse(inc.resolved_at) : Date.now(); const elapsedTo = inc.resolved_at ? Date.parse(inc.resolved_at) : Date.now();
const responsible = inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) const responsible = inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to)
: inc.acknowledged_by_id != null ? who(inc.acknowledged_by_id, inc.acknowledged_by) : ackActorLabel() || 'Unassigned';
: 'Unassigned';
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' }, rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))), h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`), inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
@@ -142,7 +151,7 @@ function facts() {
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` })); add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
if (inc.acknowledged_at) { if (inc.acknowledged_at) {
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`); add('Acknowledged', `${ackActorLabel()} · ${when(inc.acknowledged_at)}`);
} }
add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned'); add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned');
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) { if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
@@ -206,9 +215,19 @@ function alertItem(a) {
// ---------- timeline ---------- // ---------- timeline ----------
// actorLabel renders whoever performed ev, human or service account — the
// two are mutually exclusive (migration 015). null means the server acted:
// ev.user_id == null no longer means that by itself, now that a service
// account's events also leave it null.
function actorLabel(ev, named = false) {
if (ev.user_id != null) return named ? (ev.username || 'someone') : who(ev.user_id, ev.username);
if (ev.service_account_id != null) return ev.service_account_name || 'a service account';
return null;
}
// named spells users out instead of "you", for text that leaves this page. // named spells users out instead of "you", for text that leaves this page.
function eventText(ev, named = false) { function eventText(ev, named = false) {
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null; const person = actorLabel(ev, named);
const strong = (t) => h('span', { class: 'who', text: t || 'someone' }); const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
const alertName = () => { const alertName = () => {
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id); const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
@@ -453,9 +472,25 @@ function quickActions() {
return div; return div;
} }
// Desktop has room to show what a phone folds into the More sheet below — see
// the .action-extra/.more-btn rules in app.css. Resolved/archived incidents
// already say everything via primaryAction()/secondaryAction(), so there is
// nothing extra to surface for them.
function extraActions() {
if (!isOpen()) return [];
const out = [
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'),
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: addNote }, icon('note'), 'Add note…'),
];
out.push(inc.status === 'acknowledged'
? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack')
: h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve…'));
return out;
}
function actionBar() { function actionBar() {
const more = h('button', { class: 'btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More'); const more = h('button', { class: 'btn more-btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), more); const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), ...extraActions(), more);
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true; if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
return bar; return bar;
} }