Show the escalation ladder as a list, with who it would page and where it is

Team -> Escalation was the draft form on the page, which showed the
ladder only as inputs. It is now a table in the style of Switches and
Sources: a row per level with a status badge, who it pages, the wait
before the next level, and the open incidents currently waiting on it.
Below it, the repeat count, the fallback topic and when the ladder last
escalated (linking the incident). The editor moved into an "Edit ladder"
sheet, so a poll of the page underneath can no longer throw away half an
edit, and the page-level draft state went with it.

Targets are resolved to who they mean today, and the badge says what
would actually happen: Ready, Escalating (an unanswered incident has
climbed to level 2 or higher), or Pages nobody. The last is the one worth
seeing before an incident finds it: an empty rota, a person with no ntfy
topic or a disabled account each make a rung a silence with a number on
it, and the target says which. The rules are pageLevel's own, so the
page cannot promise a page the notifier would skip.

"Last escalated" comes from the escalated timeline events that already
exist, so there is no migration. Acknowledging or resolving takes an
incident off the ladder, so Escalating clears then while the history
stays.

API: GET /escalation gains status and waiting per level, username,
reachable and problem per target, and last_escalated_at and
last_escalated_incident_id. Output only and additive; PUT is unchanged
and terdut-tui needs nothing.
This commit is contained in:
Niklas Ye
2026-09-26 08:28:24 +02:00
parent dc92f51cf8
commit 1f1faa437c
5 changed files with 457 additions and 73 deletions
+185 -1
View File
@@ -346,10 +346,194 @@ func handleGetEscalation(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, escalationResponse(policy, teamID))
view, err := escalationStatus(r.Context(), db, teamID, policy)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, view)
}
}
// Level statuses, as the Escalation page colours them.
const (
levelReady = "ready"
levelEscalating = "escalating"
levelUnreachable = "unreachable"
)
// escalationTargetView is a target with who it means today and whether that
// person can actually be woken. The extra fields are output only: the PUT body
// is the plain escalationTargetJSON, and anything else in it is ignored.
type escalationTargetView struct {
escalationTargetJSON
// Username is who the target resolves to right now: the named person, or
// whoever the rota says is on call today. Empty when nobody is.
Username string `json:"username,omitempty"`
// Reachable is whether a page to this target would go anywhere, and Problem
// says why not when it would not — the same conditions pageLevel skips on.
Reachable bool `json:"reachable"`
Problem string `json:"problem,omitempty"`
}
type escalationLevelView struct {
Position int64 `json:"position"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Targets []escalationTargetView `json:"targets"`
// Status is unreachable when no target of the level could be woken — a rung
// that looks configured and pages nobody, which is worth seeing before an
// incident finds it — escalating when an unanswered incident has climbed to
// it, and ready otherwise.
Status string `json:"status"`
// Waiting lists the open, unacknowledged incidents currently on this level.
Waiting []int64 `json:"waiting"`
}
type escalationView struct {
TeamID int64 `json:"team_id"`
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []escalationLevelView `json:"levels"`
// LastEscalatedAt is when an incident of this team last moved up the ladder,
// or ran off the end of it, and LastEscalatedIncidentID which one. Absent
// when nothing ever has: a ladder nobody has needed yet.
LastEscalatedAt *time.Time `json:"last_escalated_at,omitempty"`
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id,omitempty"`
}
// escalationStatus is a team's ladder together with what it would do right now
// and what it has been doing. The resolution follows pageLevel's rules, so the
// page cannot promise a page that the notifier would skip.
func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *escalationPolicy) (escalationView, error) {
base := escalationResponse(policy, teamID)
out := escalationView{
TeamID: teamID, RepeatCount: base.RepeatCount, FallbackTopic: base.FallbackTopic,
Levels: []escalationLevelView{},
}
if !policy.configured() {
return out, nil
}
onCall, err := currentOnCall(ctx, db, teamID)
if err != nil {
return out, err
}
type account struct {
username string
topic bool
disabled bool
}
accounts := map[int64]account{}
lookup := func(id int64) (account, error) {
if a, ok := accounts[id]; ok {
return a, nil
}
var a account
var topic *string
var disabledAt *int64
if err := db.QueryRowContext(ctx,
"SELECT username, ntfy_topic, disabled_at FROM users WHERE id = $1", id).
Scan(&a.username, &topic, &disabledAt); err != nil {
return a, err
}
a.topic = topic != nil && *topic != ""
a.disabled = disabledAt != nil
accounts[id] = a
return a, nil
}
waiting := map[int64][]int64{}
rows, err := db.QueryContext(ctx, `
SELECT id, escalation_level FROM incidents
WHERE team_id = $1 AND resolved_at IS NULL AND archived_at IS NULL
AND status = 'triggered' AND escalation_level > 0
ORDER BY id`, teamID)
if err != nil {
return out, err
}
for rows.Next() {
var id, level int64
if err := rows.Scan(&id, &level); err != nil {
rows.Close()
return out, err
}
waiting[level] = append(waiting[level], id)
}
rows.Close()
if err := rows.Err(); err != nil {
return out, err
}
for _, l := range base.Levels {
level := escalationLevelView{
Position: l.Position, TimeoutSeconds: l.TimeoutSeconds,
Targets: []escalationTargetView{}, Waiting: []int64{},
}
if w := waiting[l.Position]; w != nil {
level.Waiting = w
}
anyReachable := false
for _, t := range l.Targets {
view := escalationTargetView{escalationTargetJSON: t}
userID := t.UserID
if t.Kind == "oncall" {
userID = onCall
}
switch {
case userID == nil:
view.Problem = "nobody is on call today"
default:
a, err := lookup(*userID)
switch {
case err != nil:
view.Problem = "account not found"
case a.disabled:
view.Username, view.Problem = a.username, "account is disabled"
case !a.topic:
view.Username, view.Problem = a.username, "has no ntfy topic"
default:
view.Username, view.Reachable = a.username, true
}
}
anyReachable = anyReachable || view.Reachable
level.Targets = append(level.Targets, view)
}
switch {
case !anyReachable:
level.Status = levelUnreachable
case l.Position >= 2 && len(level.Waiting) > 0:
level.Status = levelEscalating
default:
level.Status = levelReady
}
out.Levels = append(out.Levels, level)
}
var incidentID, at int64
switch err := db.QueryRowContext(ctx, `
SELECT e.incident_id, e.created_at
FROM incident_events e JOIN incidents i ON i.id = e.incident_id
WHERE i.team_id = $1 AND e.type = $2
ORDER BY e.created_at DESC, e.id DESC LIMIT 1`, teamID, evEscalated).
Scan(&incidentID, &at); {
case err == sql.ErrNoRows:
case err != nil:
return out, err
default:
t := time.Unix(at, 0).UTC()
out.LastEscalatedAt, out.LastEscalatedIncidentID = &t, &incidentID
}
return out, nil
}
type escalationLevelJSON struct {
Position int64 `json:"position"`
TimeoutSeconds int64 `json:"timeout_seconds"`