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:
@@ -383,3 +383,122 @@ func TestEscalation_SkipsUnreachableTargets(t *testing.T) {
|
||||
t.Errorf("a target with no topic should page nothing, paged %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The ladder as the Escalation page reads it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ladderLevel struct {
|
||||
Status string `json:"status"`
|
||||
Waiting []int64 `json:"waiting"`
|
||||
Targets []struct {
|
||||
Kind string `json:"kind"`
|
||||
Username string `json:"username"`
|
||||
Reachable bool `json:"reachable"`
|
||||
Problem string `json:"problem"`
|
||||
} `json:"targets"`
|
||||
}
|
||||
|
||||
type ladderView struct {
|
||||
Levels []ladderLevel `json:"levels"`
|
||||
LastEscalatedAt *string `json:"last_escalated_at"`
|
||||
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id"`
|
||||
}
|
||||
|
||||
func readLadder(t *testing.T, s *ts) ladderView {
|
||||
t.Helper()
|
||||
var v ladderView
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil), &v)
|
||||
return v
|
||||
}
|
||||
|
||||
// Targets say who they mean today, so "whoever is on call" is a name and not a
|
||||
// promise.
|
||||
func TestEscalation_StatusResolvesTargets(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
v := readLadder(t, s)
|
||||
if len(v.Levels) != 2 {
|
||||
t.Fatalf("expected 2 levels, got %d", len(v.Levels))
|
||||
}
|
||||
if got := v.Levels[0].Targets[0]; got.Kind != "oncall" || got.Username != "admin" || !got.Reachable {
|
||||
t.Errorf("the rota target should resolve to the person on call, got %+v", got)
|
||||
}
|
||||
if got := v.Levels[1].Targets[0]; got.Username != "second" || !got.Reachable {
|
||||
t.Errorf("the named target should be reachable, got %+v", got)
|
||||
}
|
||||
if v.Levels[0].Status != "ready" || v.Levels[1].Status != "ready" || v.LastEscalatedAt != nil {
|
||||
t.Errorf("an idle, healthy ladder is ready and has never escalated, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A rung that would page nobody is called out before an incident finds it.
|
||||
func TestEscalation_StatusFlagsUnreachableLevels(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
silent := teamUser(t, s, "silent", "terdut-silent")
|
||||
ladder(t, s, silent, 0, "terdut-fallback")
|
||||
|
||||
// Nobody on call today, and the named person loses their topic.
|
||||
s.exec(t, "DELETE FROM schedule_entries")
|
||||
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
|
||||
|
||||
v := readLadder(t, s)
|
||||
if v.Levels[0].Status != "unreachable" || v.Levels[0].Targets[0].Problem != "nobody is on call today" {
|
||||
t.Errorf("an empty rota should make level 1 unreachable, got %+v", v.Levels[0])
|
||||
}
|
||||
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no ntfy topic" {
|
||||
t.Errorf("a person with no topic should make level 2 unreachable, got %+v", v.Levels[1])
|
||||
}
|
||||
|
||||
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
|
||||
if p := readLadder(t, s).Levels[1].Targets[0].Problem; p != "account is disabled" {
|
||||
t.Errorf("a disabled account should say so, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Where unanswered incidents are right now, and when the ladder last did its
|
||||
// job.
|
||||
func TestEscalation_StatusShowsWhoIsWaitingAndLastEscalation(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-wait", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
// On level 1 it is waiting, which is normal and not yet an escalation.
|
||||
v := readLadder(t, s)
|
||||
if len(v.Levels[0].Waiting) != 1 || v.Levels[0].Status != "ready" || v.LastEscalatedAt != nil {
|
||||
t.Fatalf("a fresh incident waits on level 1 quietly, got %+v", v)
|
||||
}
|
||||
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t)
|
||||
v = readLadder(t, s)
|
||||
if v.Levels[1].Status != "escalating" || len(v.Levels[1].Waiting) != 1 || v.Levels[1].Waiting[0] != 1 {
|
||||
t.Errorf("level 2 should be escalating with the incident on it, got %+v", v.Levels[1])
|
||||
}
|
||||
if v.LastEscalatedAt == nil || v.LastEscalatedIncidentID == nil || *v.LastEscalatedIncidentID != 1 {
|
||||
t.Errorf("the escalation should be recorded, got %+v", v)
|
||||
}
|
||||
|
||||
// Somebody answers: nothing is waiting, but the history stays.
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||
v = readLadder(t, s)
|
||||
if v.Levels[1].Status != "ready" || len(v.Levels[1].Waiting) != 0 || v.LastEscalatedAt == nil {
|
||||
t.Errorf("an acknowledged incident stops waiting but stays in the history, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// No ladder is a real answer, not an error.
|
||||
func TestEscalation_StatusWithoutALadder(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
v := readLadder(t, s)
|
||||
if len(v.Levels) != 0 || v.LastEscalatedAt != nil {
|
||||
t.Errorf("a team with no ladder should read as empty, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user