diff --git a/README.md b/README.md index 7c3f90e..b1fa734 100644 --- a/README.md +++ b/README.md @@ -725,7 +725,7 @@ administrator who is not in the team gets the same `404` as anybody else. | `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens | | `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once | | `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires | -| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none | +| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[], last_escalated_at?, last_escalated_incident_id?}`. Empty levels means the team has none. Each level also carries `status` (`ready`, `escalating` when an unanswered incident has climbed to it, `unreachable` when nobody on it could be woken), `waiting` (ids of the open incidents on it) and, per target, `username` (who it means today — the person on call, for a rota target), `reachable` and `problem`. The extra fields are output only; `PUT` takes the plain shape | | `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it | | `GET` | `/api/teams/{teamID}/deadman/switches` | member | The team's [dead man's switches](#dead-mans-switch), each `{id, name, matcher, timeout_seconds, severity, status, last_heartbeat_at, last_triggered_at, open_incident_id, sources[]}`. `status` is `healthy`, `dead` or `dormant`; `sources` has one entry per heartbeat fingerprint. Empty when the team watches nothing | | `POST` | `/api/teams/{teamID}/deadman/switches` | **owner** | Add one: `{name?, matcher, timeout_seconds, severity?}`. `400` when the matcher names no `alertname` or holds several, or the timeout is not positive — a switch that silently watches nothing is the failure this feature exists to prevent | diff --git a/internal/api/escalation.go b/internal/api/escalation.go index 0b20c9e..2fdf28e 100644 --- a/internal/api/escalation.go +++ b/internal/api/escalation.go @@ -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"` diff --git a/internal/api/escalation_test.go b/internal/api/escalation_test.go index f1c22f3..39a428a 100644 --- a/internal/api/escalation_test.go +++ b/internal/api/escalation_test.go @@ -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) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 08429fe..a228297 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -365,7 +365,9 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh gone right, which is what the muted default already says. */ .badge.st-dormant, .badge.st-never { background: var(--surface-2); color: var(--muted); } .badge.st-active { background: var(--ok-soft); color: var(--ok); } -.badge.st-quiet { background: var(--warn-soft); color: var(--warn); } +.badge.st-quiet, .badge.st-escalating { background: var(--warn-soft); color: var(--warn); } +.badge.st-ready { background: var(--ok-soft); color: var(--ok); } +.badge.st-unreachable { background: var(--crit-soft); color: var(--crit); } .badge.sev-critical { background: var(--crit-soft); color: var(--crit); } .badge.sev-warning { background: var(--warn-soft); color: var(--warn); } .badge.sev-info { background: var(--info-soft); color: var(--info); } @@ -704,7 +706,7 @@ kbd { .card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .table-scroll { overflow-x: auto; margin-top: 12px; } .status-table th, .status-table td { white-space: nowrap; } -.status-table td:nth-child(2) { white-space: normal; min-width: 12em; } +.status-table td.wrap { white-space: normal; min-width: 12em; } .status-table .source-row td { border-bottom-style: dashed; } .status-table .source-row td:first-child { padding-left: 16px; } .source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } @@ -827,6 +829,11 @@ button.rota-day:hover { background: var(--surface-2); } .ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } .ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } .target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; } +/* A target that would not wake anybody says why, in place: it is the reason a + level is red, and the thing to go and fix. */ +.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; } +.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; } /* An integration key is shown exactly once, so it should look like something to act on rather than another row of text. */ diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index d7d38bd..94ec8ad 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -52,12 +52,10 @@ let freshKey = null; // an integration key, shown once, until the view is left export function show(route) { const next = route?.tab ?? null; // A different sub-section wants different data, so the old answer goes - // rather than being shown under the new heading until the fetch lands. The - // ladder draft goes with it: it is an edit of the page being left. + // rather than being shown under the new heading until the fetch lands. if (next !== tab) { tab = next; data = null; - draft = null; } if (!data) clear(view(), subnav(), spinner()); refresh(); @@ -185,7 +183,6 @@ function teamPicker() { teamID = Number(select.value); data = null; freshKey = null; - draft = null; refresh(); }); return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select); @@ -457,57 +454,124 @@ function memberSelect(selected) { // --- escalation ------------------------------------------------------------ -// The ladder is edited as a whole and sent as a whole, because the API replaces -// it wholesale: the levels are an order, and patching one rung would leave the -// numbering of the others undecided. -let draft = null; +const LEVEL_STATUS = { + ready: { label: 'Ready', hint: 'Somebody here can be woken.' }, + escalating: { label: 'Escalating', hint: 'An unanswered incident has climbed to this level.' }, + unreachable: { label: 'Pages nobody', hint: 'Nobody on this level can be woken right now.' }, +}; + +const levelBadge = (status) => statusBadge(LEVEL_STATUS, status, 'ready'); + +// One target as the list shows it: who it means today, and why it would not +// wake them if it would not. +function targetLine(t) { + const label = t.kind === 'oncall' + ? `On call${t.username ? ` · ${t.username}` : ''}` + : (t.username || 'Unknown person'); + return h('div', { class: 'target-line' }, + h('span', { text: label }), + t.problem && h('span', { class: 'target-problem', text: t.problem })); +} function escalationCard() { - const esc = data.escalation; - if (!draft) { - draft = { - repeat_count: esc.repeat_count || 0, - fallback_topic: esc.fallback_topic || '', - levels: (esc.levels || []).map((l) => ({ - timeout_seconds: l.timeout_seconds, - targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })), + const esc = data.escalation || {}; + const levels = esc.levels || []; + + const rows = levels.map((l) => h('tr', {}, + h('td', {}, h('strong', { text: `Level ${l.position}` })), + h('td', {}, levelBadge(l.status)), + h('td', { class: 'wrap' }, ...l.targets.map(targetLine)), + h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }), + h('td', { class: 'small' }, l.waiting?.length + ? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })]) + : h('span', { class: 'muted', text: '—' })), + )); + + const facts = []; + if (levels.length) { + const n = esc.repeat_count || 0; + if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`); + facts.push(esc.fallback_topic + ? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.'] + : 'No fallback topic: after the last level the chain just ends.'); + facts.push(esc.last_escalated_at + ? ['Last escalated ', + h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }), + ' on ', h('a', { href: `/incidents/${esc.last_escalated_incident_id}`, text: `#${esc.last_escalated_incident_id}` }), '.'] + : 'Nothing has needed to escalate yet.'); + } + + return h('div', { class: 'card' }, + h('div', { class: 'card-head' }, + h('h2', { text: 'Escalation' }), + isOwner() && h('button', { + class: 'btn', type: 'button', onclick: openLadderEditor, + text: levels.length ? 'Edit ladder' : 'Set up ladder', })), - }; - } + h('p', { class: 'muted small' }, + 'When a level’s wait passes and nobody has acknowledged, the next level is ', + 'paged. Acknowledging or resolving stops it; snoozing pauses it.'), + levels.length + ? h('div', { class: 'table-scroll' }, + h('table', { class: 'admin-table status-table' }, + h('thead', {}, h('tr', {}, + h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }), + h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))), + h('tbody', {}, rows))) + : h('p', { class: 'muted' }, + 'No ladder. An unacknowledged incident re-pages the same person every ', + 'reminder interval and nobody else is woken.'), + ...facts.map((f) => h('p', { class: 'muted small' }, f)), + ); +} - const body = []; - if (!draft.levels.length) { - body.push(h('p', { class: 'muted' }, - 'No ladder. An unacknowledged incident re-pages the same person every ', - 'reminder interval and nobody else is woken.')); - } +// The ladder is edited as a whole and sent as a whole, because the API replaces +// it wholesale: the levels are an order, and patching one rung would leave the +// numbering of the others undecided. The draft lives in the sheet, so a poll of +// the page underneath cannot throw away half an edit. +function openLadderEditor() { + const esc = data.escalation || {}; + const draft = { + repeat_count: esc.repeat_count || 0, + fallback_topic: esc.fallback_topic || '', + levels: (esc.levels || []).map((l) => ({ + timeout_seconds: l.timeout_seconds, + targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })), + })), + }; - draft.levels.forEach((level, i) => { - body.push(h('div', { class: 'ladder-level' }, - h('div', { class: 'ladder-head' }, - h('strong', { text: `Level ${i + 1}` }), - isOwner() && h('button', { - class: 'btn-sm danger', type: 'button', text: 'Remove', - onclick: () => { draft.levels.splice(i, 1); render(); }, - })), - h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => { - level.timeout_seconds = secs; - }), ' before the next level'), - h('div', { class: 'ladder-targets' }, - ...level.targets.map((t, ti) => targetRow(level, t, ti)), - isOwner() && h('button', { - class: 'btn-sm', type: 'button', text: '+ target', - onclick: () => { level.targets.push({ kind: 'oncall' }); render(); }, - })), - )); - }); + const body = h('div', { class: 'ladder-editor' }); + const problem = h('p', { class: 'load-error', hidden: true }); - if (isOwner()) { - body.push(h('button', { + const paint = () => { + const parts = []; + if (!draft.levels.length) { + parts.push(h('p', { class: 'muted small' }, 'No levels yet. Add the first one.')); + } + draft.levels.forEach((level, i) => { + parts.push(h('div', { class: 'ladder-level' }, + h('div', { class: 'ladder-head' }, + h('strong', { text: `Level ${i + 1}` }), + h('button', { + class: 'btn-sm danger', type: 'button', text: 'Remove', + onclick: () => { draft.levels.splice(i, 1); paint(); }, + })), + h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => { + level.timeout_seconds = secs; + }), ' before the next level'), + h('div', { class: 'ladder-targets' }, + ...level.targets.map((t, ti) => targetRow(level, t, ti, paint)), + h('button', { + class: 'btn-sm', type: 'button', text: '+ target', + onclick: () => { level.targets.push({ kind: 'oncall' }); paint(); }, + })), + )); + }); + parts.push(h('button', { class: 'btn-sm', type: 'button', text: '+ level', onclick: () => { draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] }); - render(); + paint(); }, })); @@ -520,31 +584,43 @@ function escalationCard() { type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all', oninput: (e) => { draft.fallback_topic = e.target.value; }, }); - body.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times')); - body.push(h('label', {}, 'Then page this ntfy topic once ', fallback)); - body.push(h('button', { - class: 'btn', type: 'button', text: 'Save ladder', - onclick: () => act(() => api.setEscalation(teamID, draft), { resetDraft: true }), - })); - } + parts.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times')); + parts.push(h('label', {}, 'Then page this ntfy topic once ', fallback)); + clear(body, ...parts); + }; + paint(); - return h('div', { class: 'card' }, - h('h2', { text: 'Escalation' }), - h('p', { class: 'muted small' }, - 'When a level’s wait passes and nobody has acknowledged, the next level is ', - 'paged. Acknowledging or resolving stops it; snoozing pauses it.'), - ...body, - ); + const save = h('button', { class: 'btn btn-primary', type: 'button', text: 'Save ladder' }); + save.addEventListener('click', async () => { + try { + await api.setEscalation(teamID, draft); + } catch (err) { + problem.textContent = err.message; + problem.hidden = false; + return; + } + closeSheet(true); + refresh(); + }); + + openSheet(() => [ + h('h2', { class: 'sheet-title', text: 'Edit ladder' }), + body, + problem, + h('div', { class: 'sheet-actions' }, + h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }), + save), + ]); } -function targetRow(level, target, index) { +function targetRow(level, target, index, repaint) { const kind = h('select', {}, h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }), h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' })); kind.addEventListener('change', () => { target.kind = kind.value; target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined; - render(); + repaint(); }); const who = target.kind === 'user' @@ -555,10 +631,10 @@ function targetRow(level, target, index) { } return h('div', { class: 'target-row' }, kind, who, - isOwner() && h('button', { + h('button', { class: 'btn-sm danger', type: 'button', text: '×', title: 'Remove this target', - onclick: () => { level.targets.splice(index, 1); render(); }, + onclick: () => { level.targets.splice(index, 1); repaint(); }, })); } @@ -577,7 +653,7 @@ function integrationsCard() { const rows = (data.integrations || []).map((i) => h('tr', {}, h('td', {}, sourceBadge(i.status)), - h('td', {}, + h('td', { class: 'wrap' }, h('strong', { text: i.name }), h('div', { class: 'muted small', text: i.kind })), // When the key last posted, and when an alert last arrived on it. They @@ -741,7 +817,7 @@ const triggeredCell = (iso, incidentID) => { function switchRows(sw) { const main = h('tr', {}, h('td', {}, switchBadge(sw.status)), - h('td', {}, + h('td', { class: 'wrap' }, h('strong', { text: sw.name }), sw.name !== sw.matcher && h('div', { class: 'muted small' }, h('code', { text: sw.matcher }))), h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)), @@ -896,14 +972,12 @@ function membersCard() { // act runs a write and reloads. Errors are shown rather than thrown away: a // 409 from the last-owner guard or the schedule's conflict rule is the server // explaining itself, and the reader needs to see it. -async function act(fn, { resetDraft = false } = {}) { +async function act(fn) { try { await fn(); error = null; - if (resetDraft) draft = null; } catch (err) { error = err.message; } - if (!resetDraft) draft = null; await refresh(); }