Compare commits
2 Commits
v0.25.0
...
2b396d22d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b396d22d6 | |||
| 1f1faa437c |
@@ -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 |
|
||||
|
||||
@@ -15,5 +15,5 @@ type: application
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.25.0
|
||||
appVersion: "v0.25.0"
|
||||
version: 0.26.0
|
||||
appVersion: "v0.26.0"
|
||||
|
||||
+185
-1
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+143
-69
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user