Compare commits

..

15 Commits

Author SHA1 Message Date
Niklas Ye 1cb09525e3 Note that a release does not bump the demos' terdut-server pin
CI / chart (push) Successful in 1s
CI / test (push) Successful in 8s
CI / security (push) Successful in 15s
terdut-demo in Ryuvia/charts, a TerdutServer CR reconciled by terdut-operator,
and the kind demo in terdut-operator both pin this image, and chart-bump only
moves the terdut-server wrapper. terdut-demo therefore sat at v0.37.0 through
v0.41.0-v0.43.0 before it was synced on 2026-10-08, and nothing said it was
behind. CLAUDE.md now lists the step after the release one: bump the demo's
tag to the same digest and its chart version, as its own PR, and read the
range's migrations first, since the demo's database migrates forward at start.

Documentation only: nothing is built from this file, so it needs no release.
2026-10-08 19:08:30 +02:00
Niklas Ye c4833067f0 Build the clusters query with Sprintf, as the other handlers do
CI / chart (push) Successful in 1s
CI / security (push) Successful in 20s
CI / test (push) Successful in 5m15s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 4s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 1m12s
Release / scan-image (push) Successful in 24s
gosec's G202 flagged the string concatenation in handleListClusters, and the
security job gates CI. The pieces joined were only placeholders and fixed
clauses, never request data, so this was not an injection; but every other
handler here builds its SQL with fmt.Sprintf over placeholders, and this one
now reads the same way. The query and its result are unchanged.
2026-10-08 18:28:26 +02:00
Niklas Ye 2bc8f336a0 Set the chart's placeholder version to 0.43.0
CI / chart (push) Successful in 1s
CI / security (push) Failing after 20s
CI / test (push) Has been cancelled
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with db474ca (0.42.1) and def0f68 (0.42.0) before it, because a
tree heading for v0.43.0 that still says 0.42.1 tells its reader
something false.
2026-10-08 18:24:55 +02:00
Niklas Ye a955356821 Filter the queue by cluster
A team with a cluster per alert source can now narrow the queue to one
cluster. A dropdown under the status chips lists the clusters, and appears
once there are two or more to choose between, the rule the team selector
follows. The choice is kept per browser, like the selected team, and dropped
if the server no longer knows the cluster rather than leaving an empty list
with no explanation. The status chip counts follow it, and a row leaves out
its cluster chip once the queue is narrowed to one.

The filter is on the server. The list is capped at 50 rows, so filtering what
is on screen would quietly miss older incidents in the Resolved and Archived
lists. GET /api/incidents takes ?cluster=<value>, matched against the
incident's `cluster` group label, and GET /api/incidents/clusters lists the
distinct values from the last 90 days (optionally for one team), so the
dropdown is not limited to what the current page happens to show. Both are
additive: no existing parameter or JSON shape changed, so terdut-tui keeps
working unchanged and has nothing it must mirror.

An incident carries the label only when `cluster` is in Alertmanager's
group_by, so the filter only sees those; the README says so.
2026-10-08 18:23:28 +02:00
Niklas Ye db474ca909 Set the chart's placeholder version to 0.42.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 20s
CI / test (push) Successful in 5m12s
Release / test (push) Successful in 10s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / scan-image (push) Successful in 1s
Release / image (push) Successful in 1m4s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with def0f68 (0.42.0) and b848143 (0.41.3) before it, because a
tree heading for v0.42.1 that still says 0.42.0 tells its reader
something false.
2026-10-08 18:09:23 +02:00
Niklas Ye a23e88c16d Put the cluster first in an ntfy page title
A phone's lock screen cuts a long title off at the end, and an incident title
keeps its grouping labels there: "PodRestarting (cluster=prod-eu,
namespace=shop)". For a team with a Kubernetes cluster per alert source, the
cluster is the first thing a person wants and the first thing lost.

When the incident has a `cluster` group label, the title of the page now
leads with it, "[prod-eu] PodRestarting (namespace=shop)", and the label is
dropped from the parenthesis so it is not said twice, along with the
parenthesis itself if nothing else is left. The reminder and the resolution
use the same title, and the label is the one the web UI's chip reads. The
message body is unchanged.

An incident without a cluster label gets the same title as before, and
incidents that are already open keep theirs; only pages sent from now on
change. The constant originLabel names the label, as ORIGIN_LABEL does in the
web UI.

No endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
2026-10-08 18:07:59 +02:00
Niklas Ye def0f68d00 Set the chart's placeholder version to 0.42.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 20s
CI / test (push) Successful in 5m11s
Release / test (push) Successful in 9s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 25s
Release / image (push) Successful in 1m6s
Release / scan-image (push) Successful in 25s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with b848143 (0.41.3) and 7665e5e (0.41.2) before it, because a
tree heading for v0.42.0 that still says 0.41.3 tells its reader
something false.
2026-10-08 17:26:16 +02:00
Niklas Ye fb86a18988 Show which cluster an incident came from, as a chip
A team with one Alertmanager per Kubernetes cluster could not tell at a
glance where an incident started: the cluster was only a word inside the
title. When an alert carries a `cluster` label, the queue rows, the incident
page and the alerts list now show it as a chip in that cluster's colour, a
stable pick from the existing six-colour palette. A queue row drops
`cluster=...` from its title, since the chip says it, and the incident page
keeps the full title.

An incident has the label only when it is in Alertmanager's group_by, which
is also what keeps two clusters' identical alerts apart: incidents are matched
on the team and the groupKey, and the groupKey does not include external
labels. The README has a section on the two settings (Prometheus
externalLabels and group_by). The alerts list reads the label from the alert
itself, so it shows the chip with only the external label.

Web UI and docs only: no endpoint or JSON shape changed, so nothing to mirror
in terdut-tui. Nothing changes for a team whose alerts have no cluster label.
2026-10-08 17:24:52 +02:00
Niklas Ye b848143471 Set the chart's placeholder version to 0.41.3
CI / security (push) Successful in 4m45s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 3s
CI / test (push) Successful in 9m4s
Release / image (push) Successful in 2m15s
Release / scan-image (push) Successful in 40s
Release / binaries (push) Successful in 3m1s
CI / chart (push) Successful in 1s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 7665e5e (0.41.2) and 3a96b20 (0.41.1) before it, because a
tree heading for v0.41.3 that still says 0.41.2 tells its reader
something false.
2026-10-08 15:13:42 +02:00
Niklas Ye 942517c7a8 Keep the scroll fade at the screen edge on the queue's filter row
The fade that hints at more filters to the right was an element inside the
scrolling row, so it scrolled away with the chips instead of staying at the
edge; the comment on it claimed the opposite. It is now a mask on the strip
itself, applied only while there is more to scroll to (fadeOnOverflow in
ui.js sets data-more), so it stays put and disappears at the end instead of
dimming the last chip.

The Team and Admin tab strips had an always-on mask from the earlier
"Sources is cut off" fix, which dimmed their last tab even when fully
scrolled; they use the same mechanism now.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 15:12:16 +02:00
Niklas Ye 7665e5e52f Set the chart's placeholder version to 0.41.2
CI / chart (push) Successful in 1s
CI / security (push) Successful in 26s
CI / test (push) Successful in 5m42s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 39s
Release / image (push) Successful in 1m22s
Release / scan-image (push) Successful in 27s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 3a96b20 (0.41.1) and 065b557 (0.41.0) before it, because a
tree heading for v0.41.2 that still says 0.41.1 tells its reader
something false.
2026-10-08 14:53:15 +02:00
Niklas Ye bcf1a3e99b Give the sidebar groups, and tidy the tables, disclosures and tab bar
The desktop sidebar is now grouped: Queue, On-call, Alerts and Stats; Team
and Admin; then Account at the foot, shown as the signed-in person with an
avatar and their name. The active item has a bar as well as a tint. The
queue's row of team chips is gone: the team selector is the one place the
team is chosen, and the chips offered the same choice a second time.

Tables get 16px between columns, so a right-aligned count no longer touches
the text beside it. An empty SSO group reads "Not configured" instead of a
dash, and the main action on each of those pages (New source, New switch,
Add member, Assign, and the admin Add, Create invite and Create) is a filled
button, with Edit and Cancel staying secondary. The bare triangles on
"Grouped by" and the label lists are real disclosure buttons: a chevron
that turns and a 44px target on a touch screen.

On a phone the More tab was a <button> that kept the browser's grey box, so
it looked highlighted next to four plain links. It now matches them, the
labels are 12px and the open tab's icon is filled.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:51:50 +02:00
Niklas Ye 3a96b20cbe Set the chart's placeholder version to 0.41.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 27s
CI / test (push) Successful in 5m55s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 41s
Release / image (push) Successful in 1m19s
Release / scan-image (push) Successful in 1s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 065b557 (0.41.0) and ead5df1 (0.40.0) before it, because a
tree heading for v0.41.1 that still says 0.41.0 tells its reader
something false.
2026-10-08 14:19:12 +02:00
Niklas Ye 7b9d309d13 Draw the rota as shift bars, and the escalation chain as a ladder
The rota put an initial on every day, so one person covering a week was
seven identical chips and a legend to decode them. A shift is now one bar
across its days with the person's name on it; a flat end with a chevron
means it carries on across the row break. Days with nobody on call are a
hatched amber bar while they can still be fixed and a quiet grey one once
they are history, and the footer is a green banner when the month is
covered or an amber one when it is not. Tapping a day still opens it: the
bars ignore the pointer, so the tap reaches the cell underneath. The month
title was monospace because its button borrowed .label, which belongs to
label chips; it has its own class now, and a Today button sits beside the
arrows.

The escalation card was a five-column table. It is now the ladder it
describes: a numbered step per level with its status and who it pages, the
wait before the next level on its own line, and what happens after the last
one at the bottom. With no fallback topic that last step is an amber
callout, with an Add fallback button for owners that opens the editor on the
field. That gap is what incident #36 hit.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:17:49 +02:00
Niklas Ye 7412456c5a Pad the settings cards, and make status and timeline readable at a glance
The Team settings cards (Escalation, Sources, Members, Switches, Single
sign-on) printed their text flush against the border with the button in the
corner: .card has never had padding and these never added any. A card with
a header row now pads itself, with the title left, the button right and a
divider before the content.

The incident page no longer carries the primary action twice. The copy up
by the status is gone and the sticky bar keeps it; Note is the timeline's
link, with Copy in the bar for a resolved incident, and the phone's More
sheet drops what the bar already shows. In the queue, a row's title wraps
to two lines so the namespace that tells rows apart is no longer cut off,
zero counts on the filter chips are dimmed, and a row omits the status the
filter already states and the team once the queue is narrowed to one.

Status colours failed 4.5:1 against their own fill in the light theme
(warning 3.6, info 4.1, critical 4.4, ok 4.45, snooze 4.49), so the light
tokens are darker; the dark theme already passed and is unchanged. Severity
badges now carry a shape as well as a colour, and each timeline event has an
icon. An escalation that ran out of levels, a failed notification and a
silent heartbeat stand out in amber or red.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:07:24 +02:00
21 changed files with 892 additions and 242 deletions
+11 -1
View File
@@ -14,7 +14,7 @@ Preconditions and the plan, without side effects:
Config is `.release.conf` here plus `make release-vars`. The process itself lives in
`~/.claude/skills/release/`; why it is shaped this way is in README.md §Releasing.
Two things about this repo specifically:
Three things about this repo specifically:
- **The image is scanned after it is published, not before.** `scan-image` runs trivy
against the pushed image, because trivy cannot read a locally built one on this runner.
@@ -25,6 +25,16 @@ Two things about this repo specifically:
so `chart-bump` needs `--image "$IMAGE"` to know which one moves. That sidecar backs up
SQLite; the Postgres move (#2) retires it in favour of a `postgresql` CR with a k8up
`pg_dump` annotation, after which only the app image's tag is left.
- **Two demos pin this image, and `chart-bump` moves neither.** `terdut-demo` in
`Ryuvia/charts` is a `TerdutServer` CR that terdut-operator reconciles, and its
`values.yaml` `image.tag` is meant to match production's pin (same digest). The kind demo
in terdut-operator (`examples/demo/01-server.yaml`) pins a tag too. A release only bumps
the `terdut-server` wrapper, so both drift silently: `terdut-demo` sat at v0.37.0 through
v0.41.0-v0.43.0 until it was synced on 2026-10-08. After a release, bump `terdut-demo`'s
tag to the same `image-digest` and its `Chart.yaml` `version:` (Flux reconciles on
ChartVersion), as its own PR, and say in the release report whether you did. Neither
demo has anything but the pin to change, but read the version range's migrations first:
the demo's Postgres migrates forward at startup.
## Checks
+25 -1
View File
@@ -444,6 +444,29 @@ high-water mark — the highest `severity` label any of its alerts has carried
an incident that hit `critical` still reads as critical after the critical alert
clears.
### Several clusters, one team
A team with one Alertmanager per Kubernetes cluster, each posting to its own
source, needs two settings or the clusters run together.
1. Give every alert a `cluster` label at the source. In Prometheus that is
`externalLabels: {cluster: prod-eu}` (kube-prometheus-stack:
`prometheus.prometheusSpec.externalLabels`).
2. Add `cluster` to `group_by` in `alertmanager.yml`.
The second one is the one that matters. Incidents are matched on the team and
Alertmanager's `groupKey`, and the `groupKey` does not include external labels:
without `cluster` in `group_by`, the same alert in two clusters has the same
key and joins one incident. With it, each cluster gets its own, `cluster` is in
the incident's `group_labels`, and the web UI shows it as a coloured chip on the
queue, the incident and the alert list, instead of leaving it in the title.
An alert that is not grouped by `cluster` still shows the chip on the alert
list, which reads the label from the alert itself.
The queue has a cluster dropdown once there are two or more values to choose
between. It filters on the incident's `cluster` group label
(`GET /api/incidents?cluster=...`), so it only sees incidents grouped by it.
### An incident opens only on a new occurrence
An incident opens when an alert **transitions into firing**: a fingerprint that
@@ -926,7 +949,8 @@ the team gets the same `404` as anybody else.
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/incidents` | List incidents. Filters: `?status=triggered\|acknowledged\|resolved`, `?severity=`, `?assigned_to=<user id>`, `?archived=true`, `?snoozed=true`, `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD`, `?sort=severity`, `?limit=` (default 50, max 500) |
| `GET` | `/api/incidents` | List incidents. Filters: `?status=triggered\|acknowledged\|resolved`, `?severity=`, `?assigned_to=<user id>`, `?archived=true`, `?snoozed=true`, `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD`, `?sort=severity`, `?cluster=<value of the cluster group label>`, `?limit=` (default 50, max 500) |
| `GET` | `/api/incidents/clusters` | The distinct `cluster` values on the caller's incidents from the last 90 days, sorted (`?team_id=` narrows it). An empty array when nothing carries the label |
| `GET` | `/api/incidents/{id}` | Get single incident, with its alerts inline |
| `GET` | `/api/incidents/{id}/alerts` | Alerts under this incident |
| `GET` | `/api/incidents/{id}/timeline` | Full event history, chronological |
+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:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.41.0
appVersion: "v0.41.0"
version: 0.43.0
appVersion: "v0.43.0"
+52
View File
@@ -13,6 +13,52 @@ import (
"github.com/go-chi/chi/v5"
)
// handleListClusters answers GET /api/incidents/clusters: the distinct values of
// the origin label across the caller's incidents from the last 90 days, sorted,
// so the queue can offer them as a filter. Optional team_id narrows it to one
// team. Empty when nothing carries the label, which is how the UI knows to show
// no filter at all.
func handleListClusters(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
args := &sqlArgs{}
where := []string{
"team_id = ANY(" + args.add(callerTeamIDs(r.Context())) + ")",
"triggered_at >= " + args.add(time.Now().AddDate(0, 0, -90).Unix()),
}
if team := r.URL.Query().Get("team_id"); team != "" {
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
where = append(where, "team_id = "+args.add(n))
}
}
label := args.add(originLabel)
rows, err := db.QueryContext(r.Context(),
fmt.Sprintf("SELECT DISTINCT group_labels->>%[1]s AS v FROM incidents WHERE %[2]s AND group_labels->>%[1]s <> '' ORDER BY v LIMIT 200",
label, strings.Join(where, " AND ")),
args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
clusters := []string{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
clusters = append(clusters, v)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, clusters)
}
}
func handleListIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
@@ -53,6 +99,12 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
if severity := q.Get("severity"); severity != "" {
where = append(where, "i.severity = "+args.add(severity))
}
// Where it came from: the value of the origin label (originLabel, by
// convention "cluster") among the incident's group labels. An incident has
// it only when the label is in Alertmanager's group_by.
if cluster := q.Get("cluster"); cluster != "" {
where = append(where, "i.group_labels->>"+args.add(originLabel)+" = "+args.add(cluster))
}
if assignee := q.Get("assigned_to"); assignee != "" {
if n, err := strconv.ParseInt(assignee, 10, 64); err == nil {
where = append(where, "i.assigned_to = "+args.add(n))
+35
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"time"
@@ -972,3 +973,37 @@ func TestServiceAccount_AssignArchiveUnarchiveRecordActor(t *testing.T) {
}
}
}
// The queue can be narrowed to one cluster, and the distinct clusters are
// offered so the filter has something to list.
func TestIncidents_FilterByCluster(t *testing.T) {
s := newTS(t)
for _, c := range []string{"prod-eu", "prod-us"} {
fireGroupedAs(t, s, "fp-"+c,
map[string]string{"alertname": "PodRestarting", "cluster": c, "namespace": "n"},
`{}:{alertname="PodRestarting",cluster="`+c+`",namespace="n"}`)
}
// A cluster-less incident exists too, and must never match a cluster filter.
fireGroupedAs(t, s, "fp-none", map[string]string{"alertname": "DiskFull"}, `{}:{alertname="DiskFull"}`)
if got := len(listIncidents(t, s, "")); got != 3 {
t.Fatalf("expected 3 open incidents, got %d", got)
}
eu := listIncidents(t, s, "?cluster=prod-eu")
if len(eu) != 1 {
t.Fatalf("expected 1 incident for prod-eu, got %d", len(eu))
}
if labels, _ := eu[0]["group_labels"].(map[string]any); labels["cluster"] != "prod-eu" {
t.Errorf("filtered to the wrong cluster: %v", eu[0]["group_labels"])
}
if got := len(listIncidents(t, s, "?cluster=nowhere")); got != 0 {
t.Errorf("an unknown cluster should match nothing, got %d", got)
}
var clusters []string
decode(t, s.req(t, http.MethodGet, "/api/incidents/clusters", nil), &clusters)
if want := "prod-eu,prod-us"; strings.Join(clusters, ",") != want {
t.Errorf("clusters = %v, want %s", clusters, want)
}
}
+48 -3
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"log"
"net/http"
"regexp"
"strings"
"time"
@@ -409,9 +410,11 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID)
}
title := pageTitle(inc)
switch n.kind {
case notifyResolved:
msg.Title = "Resolved: " + inc.Title
msg.Title = "Resolved: " + title
msg.Message = "All alerts stopped firing after " +
humanDuration(time.Since(inc.TriggeredAt))
msg.Priority = ntfyPriorityLow
@@ -419,9 +422,9 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
return msg
case notifyReminder:
msg.Title = "Still unacknowledged: " + inc.Title
msg.Title = "Still unacknowledged: " + title
default:
msg.Title = inc.Title
msg.Title = title
}
severity := derefString(inc.Severity)
@@ -443,6 +446,48 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
return msg
}
// originLabel is the label that says where an alert came from, for a team with
// several Kubernetes clusters behind it. It comes from Prometheus's
// externalLabels and reaches an incident through Alertmanager's group_by; the
// web UI reads the same label, and the README ("Several clusters, one team")
// explains how to set it up.
const originLabel = "cluster"
// pageTitle is the incident's title for a notification. A phone's lock screen
// cuts a long title off at the end, and the incident title puts the grouping
// labels there, so the cluster would be the first thing lost. When the incident
// has an origin it leads instead, "[prod-eu] PodRestarting (namespace=foo)", and
// is dropped from the parenthesis so it is not said twice. A title that is not
// in incidentTitle's "name (k=v, k=v)" shape keeps its text and gains the prefix.
func pageTitle(inc models.Incident) string {
origin := inc.GroupLabels[originLabel]
if origin == "" {
return inc.Title
}
return "[" + origin + "] " + titleWithoutLabel(inc.Title, originLabel, origin)
}
var titleShape = regexp.MustCompile(`(?s)^(.*?) \((.*)\)$`)
// titleWithoutLabel removes "key=value" from the parenthesised tail of a title
// built by incidentTitle, and the parentheses with it if nothing else is left.
func titleWithoutLabel(title, key, value string) string {
m := titleShape.FindStringSubmatch(title)
if m == nil {
return title
}
var rest []string
for _, part := range strings.Split(m[2], ", ") {
if part != key+"="+value {
rest = append(rest, part)
}
}
if len(rest) == 0 {
return m[1]
}
return m[1] + " (" + strings.Join(rest, ", ") + ")"
}
// ntfy's priority scale. Max is the one that overrides the phone's quiet
// settings, which is the whole point of paging on critical.
const (
+82
View File
@@ -1,6 +1,7 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -164,10 +165,91 @@ func fireCritical(t *testing.T, s *ts) {
}, "{}:{alertname=\"DiskFull\"}")
}
// fireGrouped posts one critical alert whose Alertmanager group carries the
// given labels, the way group_by puts them on the webhook.
func fireGrouped(t *testing.T, s *ts, groupLabels map[string]string, groupKey string) {
t.Helper()
fireGroupedAs(t, s, "fp-grouped", groupLabels, groupKey)
}
// fireGroupedAs is fireGrouped with its own alert fingerprint, for a test that
// needs several alerts open at once.
func fireGroupedAs(t *testing.T, s *ts, fingerprint string, groupLabels map[string]string, groupKey string) {
t.Helper()
payload := map[string]any{
"version": "4", "status": "firing", "groupKey": groupKey, "groupLabels": groupLabels,
"alerts": []map[string]any{amAlert(fingerprint, "PodRestarting", "firing",
"2026-05-20T10:00:00Z", zeroTime, map[string]string{"severity": "critical"})},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
"application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post webhook: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("webhook returned %d", resp.StatusCode)
}
}
// ---------------------------------------------------------------------------
// Delivery
// ---------------------------------------------------------------------------
// A phone cuts a long title off at the end, and the incident title keeps the
// grouping labels there, so the cluster leads the page instead.
func TestNotify_ClusterLeadsTheTitle(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
fireGrouped(t, s, map[string]string{
"alertname": "PodRestarting", "cluster": "prod-eu", "namespace": "shop",
}, `{}:{alertname="PodRestarting",cluster="prod-eu",namespace="shop"}`)
s.sweepNotify(t)
msgs := f.messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 push, got %d", len(msgs))
}
if want := "[prod-eu] PodRestarting (namespace=shop)"; msgs[0].Title != want {
t.Errorf("title = %q, want %q", msgs[0].Title, want)
}
}
// Only the cluster is the whole grouping: no parenthesis is left behind.
func TestNotify_ClusterAloneLeavesNoParenthesis(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{})
fireGrouped(t, s, map[string]string{"alertname": "PodRestarting", "cluster": "prod-eu"},
`{}:{alertname="PodRestarting",cluster="prod-eu"}`)
s.sweepNotify(t)
msgs := f.messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 push, got %d", len(msgs))
}
if want := "[prod-eu] PodRestarting"; msgs[0].Title != want {
t.Errorf("title = %q, want %q", msgs[0].Title, want)
}
}
// Nothing changes for a team whose alerts have no cluster label.
func TestNotify_NoClusterKeepsTheTitle(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{})
fireGrouped(t, s, map[string]string{"alertname": "PodRestarting", "namespace": "shop"},
`{}:{alertname="PodRestarting",namespace="shop"}`)
s.sweepNotify(t)
msgs := f.messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 push, got %d", len(msgs))
}
if want := "PodRestarting (namespace=shop)"; msgs[0].Title != want {
t.Errorf("title = %q, want %q", msgs[0].Title, want)
}
}
func TestNotify_TriggeredIncidentPagesOnCall(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
+1
View File
@@ -145,6 +145,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Get("/api/alerts/{id}", handleGetAlert(db))
r.Get("/api/incidents", handleListIncidents(db))
r.Get("/api/incidents/clusters", handleListClusters(db))
r.Get("/api/incidents/{id}", handleGetIncident(db))
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
+206 -65
View File
@@ -20,23 +20,23 @@
--accent-text: #ffffff;
--accent-soft: #e8eefc;
--crit: #d0342c;
--crit: #c22d26;
--crit-soft: #fdecea;
--warn: #b86e00;
--warn: #8f5500;
--warn-soft: #fdf3e1;
--info: #2f6fdf;
--info: #245fc7;
--info-soft: #e9f0fd;
--ok: #1d7f4c;
--ok: #1a7445;
--ok-soft: #e6f5ec;
--snooze: #6b5bd2;
--snooze: #6050c8;
--snooze-soft: #efedfb;
/* Two hues that mean nothing on their own. The rota needs six colours to
tell six people apart and the palette above only has four that are not
already an alarm. */
--teal: #0f7d8c;
--teal: #0c7180;
--teal-soft: #e3f4f6;
--pink: #b3427e;
--pink: #a63b73;
--pink-soft: #fbe8f2;
--radius: 10px;
@@ -296,6 +296,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
border-top: 1px solid var(--border);
}
.nav-brand { display: none; }
.nav-sep, .nav-avatar { display: none; }
/* Hidden here (shown from 900px below): on the phone bar the team switcher
lives in the topbar instead, as #team-selector-mobile. */
.nav-team-selector { display: none; }
@@ -305,7 +306,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
block below cancels it back to a natural-width row item. */
flex: 1 1 0;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600;
color: var(--muted); font-size: 12px; font-weight: 600;
/* The More tab is a <button>; without this it keeps the browser's grey box
and reads as a highlighted tab beside four plain links. */
background: none; border: 0; font-family: inherit; cursor: pointer;
/* min-width lets a column shrink below its label's natural width, which is
what stops six tabs widening the bar past the screen. */
min-width: 0; padding: 0 2px;
@@ -320,6 +324,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.nav-link[aria-current="page"] { color: var(--accent); }
/* The open tab's icon is filled, so it is not told apart by colour alone. */
.nav-link[aria-current="page"] svg { fill: currentColor; fill-opacity: 0.16; }
.nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px);
min-width: 18px; height: 18px; padding: 0 5px;
@@ -387,16 +393,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
.theme-picker { padding: 0 0 8px; }
.chip .count { margin-left: 4px; opacity: 0.7; }
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
box stays flush with its real right edge regardless of scroll position,
which turned out not to be true of position:sticky here — as a flex
item, its sticky offset interacted with the row's gap and its own
negative margin, landing short of the edge by about one gap's width. */
.chips-fade {
position: absolute; top: 0; right: 0; bottom: 0;
width: 24px;
background: linear-gradient(to right, transparent, var(--bg));
pointer-events: none;
/* Nothing in it: step back so the chips that have something stand out. */
.chip .count.zero { opacity: 0.4; }
/* A scrolling strip fades on the right edge while there is more to scroll to
(fadeOnOverflow in ui.js sets data-more). A mask on the strip itself, not an
element inside it: anything inside a scroller scrolls away with the content,
which is what the old overlay did. */
[data-more] {
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
}
/* The queue's cluster filter: a pill-shaped select under the status chips. */
.queue-origin { position: relative; display: flex; align-items: center; padding: 0 16px 8px; }
.origin-select-icon { position: absolute; left: 28px; width: 16px; height: 16px; color: var(--muted); pointer-events: none; }
.origin-select {
min-height: 34px; max-width: 100%; padding: 0 12px 0 34px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--text);
font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
}
/* ---------- lists ---------- */
@@ -422,7 +437,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
background: var(--sev, var(--border-strong));
}
.row-title {
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 650; overflow: hidden; overflow-wrap: anywhere;
/* Two lines, not one: titles differ at the end ("PodRestarting
(namespace=...") and an ellipsis cut exactly the part that tells rows apart. */
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
}
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
.row-meta {
@@ -435,7 +453,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
/* Which team's queue a row came from. Only rendered for somebody in more than
one team, so it never repeats the same word down the whole list. */
/* Separates the status chips from the team chips in the queue's filter row. */
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
.row-team {
padding: 1px 6px; border-radius: 4px;
@@ -481,6 +498,15 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.plain::before { display: none; }
.badge .badge-icon, .origin-chip .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; }
/* The cluster an incident or alert came from. Same shape as a badge, coloured
from the rcN palette (see originClass in format.js), never the severity one. */
.origin-chip {
display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
padding: 1px 8px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
@@ -516,10 +542,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
/* A copy of the sticky actionbar's primary button, right under the status
it responds to — see quickActions() in incident.js. */
.detail-quick-actions { margin-bottom: 14px; }
.detail-quick-actions .btn-primary { font-size: 16px; min-height: 44px; }
.card {
background: var(--surface);
@@ -562,11 +584,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
.alert-item-foot a { color: var(--accent); font-weight: 600; }
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; }
/* A disclosure is a button: a chevron that turns, and a 44px target on a
touch screen (the bare triangle it replaces was a few pixels wide). */
details > summary {
display: flex; align-items: center; gap: 8px;
min-height: 44px; margin: 0 -6px; padding: 0 6px; border-radius: var(--radius-sm);
cursor: pointer; color: var(--muted); font-size: 14px; font-weight: 600; list-style: none;
}
details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: "▸ "; }
details[open] > summary::before { content: "▾ "; }
details > summary::before {
content: ""; flex: none; width: 7px; height: 7px; margin: 0 5px 0 3px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(-45deg); transition: transform 0.12s;
}
details[open] > summary::before { transform: rotate(45deg); }
details > summary:hover { background: var(--surface-2); color: var(--text); }
details > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
details[open] > summary { margin-bottom: 8px; }
@media (hover: hover) and (pointer: fine) { details > summary { min-height: 32px; } }
@media (prefers-reduced-motion: reduce) { details > summary::before { transition: none; } }
/* One .tl-phase per status the incident has been through (see
timelinePhases() in incident.js) — each with its own .timeline <ol>, so
@@ -588,18 +624,26 @@ details[open] > summary { margin-bottom: 8px; }
.tl-item::before {
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
}
.tl-item:first-child::before { top: 16px; }
.tl-item:last-child::before { bottom: calc(100% - 16px); }
.tl-item:first-child::before { top: 18px; }
.tl-item:last-child::before { bottom: calc(100% - 18px); }
.tl-dot {
position: relative; z-index: 1;
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--faint);
display: grid; place-items: center;
width: 20px; height: 20px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--border-strong); color: var(--muted);
}
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); }
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
/* An event with no icon keeps the old plain dot. */
.tl-dot:empty { width: 10px; height: 10px; margin: 5px 0 0 5px; }
.tl-icon { width: 11px; height: 11px; stroke-width: 2.6; }
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); color: var(--crit); }
.tl-acknowledged .tl-dot { border-color: var(--warn); color: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); color: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); color: var(--snooze); }
.tl-note .tl-dot, .tl-resolution_note .tl-dot { border-color: var(--accent); color: var(--accent); }
.tl-problem .tl-dot { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
.tl-problem.tl-notify_failed .tl-dot, .tl-problem.tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit-soft); color: var(--crit); }
.tl-problem .tl-text { color: var(--warn); font-weight: 600; }
.tl-problem.tl-notify_failed .tl-text, .tl-problem.tl-deadman_silent .tl-text { color: var(--crit); }
.tl-body { min-width: 0; font-size: 14px; }
.tl-text { overflow-wrap: anywhere; }
.tl-text .who { font-weight: 650; }
@@ -708,7 +752,7 @@ details[open] > summary { margin-bottom: 8px; }
.you-badge { margin-left: 6px; }
.week-nav { display: flex; align-items: center; gap: 4px; }
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
.week-nav .month-label { font-size: 15px; font-weight: 650; min-width: 8em; text-align: center; }
/* The week-nav button that shows the date range, "28 Sep – 4 Oct", with the
ISO week number as secondary text inside it — a separate class from
.label above (team.js's month-nav uses that one) so its <small> isn't
@@ -817,6 +861,17 @@ kbd {
.nav-team-selector { display: inline-flex; margin: -8px 10px 14px; width: calc(100% - 20px); }
.nav-link-secondary { display: flex; }
.nav-more-btn { display: none; }
.nav-sep { display: block; height: 1px; margin: 8px 10px; background: var(--border); }
/* The account link sits at the foot of the sidebar, as the signed-in person. */
.nav-sep-account { margin-top: auto; }
.nav-account .nav-avatar {
display: grid; place-items: center; flex: none;
width: 28px; height: 28px; margin: -4px 0 -4px -4px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent);
font-size: 13px; font-weight: 750; text-transform: uppercase;
}
.nav-account .nav-avatar:not([hidden]) ~ .nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-account svg:has(~ .nav-avatar:not([hidden])) { display: none; }
.nav-link {
flex: none; flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -824,6 +879,11 @@ kbd {
}
.nav-link:hover { background: var(--surface-2); }
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
/* A 3px bar on the active item, as well as the tint. */
.nav-link[aria-current="page"]::before {
content: ""; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 3px;
border-radius: 0 3px 3px 0; background: var(--accent);
}
.nav-link svg { width: 20px; height: 20px; }
.nav-badge { position: static; margin-left: auto; }
@@ -841,8 +901,6 @@ kbd {
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
is hidden, so the chips wrap here instead: Archived stays reachable. */
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
.pane-list .chip-sep { display: none; }
.chips-fade { display: none; }
/* 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
@@ -891,9 +949,12 @@ kbd {
.admin-table th {
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
padding: 4px 16px 4px 0; border-bottom: 1px solid var(--border);
}
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
/* 16px between columns, so a right-aligned count never touches the
left-aligned text beside it ("5" against "16d ago"). */
.admin-table td { padding: 10px 16px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table th:last-child, .admin-table td:last-child { padding-right: 0; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
@@ -906,7 +967,17 @@ kbd {
Six columns do not fit a phone, so the table scrolls inside its card rather
than the page. A heartbeat under a switch with several is indented, the way
the escalation ladder indents its levels. */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
/* A card with a header row (the Team and Admin settings cards) pads itself:
plain .card has no padding, so these used to print their text flush against
the border with the button jammed in the corner. The divider separates the
title row from the content below it. */
.card:has(> .card-head) { padding: 20px; }
@media (min-width: 900px) { .card:has(> .card-head) { padding: 24px; } }
.card-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding-bottom: 14px; margin-bottom: 14px; border-bottom: 1px solid var(--border);
}
.card-head h2 { margin: 0; font-size: 17px; font-weight: 650; }
.table-scroll { overflow-x: auto; margin-top: 12px; }
.status-table th, .status-table td { white-space: nowrap; }
.status-table td.wrap { white-space: normal; min-width: 12em; }
@@ -979,23 +1050,32 @@ kbd {
too alike down a column to read, so a day carries an initial in that
person's colour and the legend underneath says whose. A shift is then a run
of one colour, which is the shape the question actually has. */
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-grid { padding: 10px 12px 4px; }
.rota-heads, .rota-row { display: grid; grid-template-columns: 2.4em repeat(7, minmax(0, 1fr)); column-gap: 2px; }
.rota-wd {
padding-bottom: 4px; text-align: center;
padding-bottom: 6px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
}
.rota-row { grid-template-rows: 26px 34px; padding: 4px 0 6px; }
.rota-row + .rota-row { border-top: 1px solid var(--border); }
/* A day is a cell that spans both of its row's lines: the number on the first,
the shift bar drawn over the second. The bar ignores the pointer, so a tap
anywhere in the cell reaches the button. */
.rota-day {
display: flex; flex-direction: column; align-items: center; gap: 4px;
min-height: 52px; padding: 6px 0 8px;
grid-row: 1 / span 2;
display: flex; justify-content: center; align-items: flex-start;
min-width: 0; padding-top: 2px;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; color: inherit;
}
button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); }
button.rota-day:focus-visible, button.rota-week:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* The week number starts each row. Quiet by default, because it is a label
first; an owner's tap on it is the second thing it does. */
.rota-week {
grid-column: 1; grid-row: 1 / span 2;
display: grid; place-items: center;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
@@ -1003,18 +1083,43 @@ button.rota-day:hover { background: var(--surface-2); }
}
button.rota-week { cursor: pointer; }
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rota-week.current { color: var(--accent); font-weight: 700; }
.rota-week.current { color: var(--text); font-weight: 700; }
/* The sheet's row of who holds each day of the week. */
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
.week-holder.past { opacity: 0.55; }
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rota-day.today { background: var(--accent-soft); }
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
.rota-day.past { opacity: 0.55; }
.rota-num {
min-width: 22px; height: 22px; padding: 0 3px; border-radius: 11px;
text-align: center; line-height: 22px;
color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums;
}
/* Today is a filled number, a marker and not the accent, which means "you can
act on this". */
.rota-day.today .rota-num { background: var(--text); color: var(--bg); font-weight: 700; }
/* The days either side of the month are real days and are drawn, but they
belong to the month you are not looking at. */
.rota-day.outside { opacity: 0.35; }
.rota-day.outside .rota-num { opacity: 0.4; }
/* A shift: a run of consecutive days held by one person, in that person's
colour (.rc1-.rc6 below). Rounded ends are where it really starts or stops;
a flat end with a chevron carries on across the row break. */
.rota-bar {
grid-row: 2; align-self: center; z-index: 1; pointer-events: none;
display: flex; align-items: center; min-width: 0; height: 28px; padding: 0 8px;
border-radius: 6px;
font-size: 13px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rota-bar.past { opacity: 0.55; }
.rota-bar.outside { opacity: 0.4; }
.rota-bar.cont-l { margin-left: -2px; padding-left: 10px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.rota-bar.cont-r { margin-right: -2px; border-top-right-radius: 0; border-bottom-right-radius: 0; }
.rota-bar.cont-l::before { content: "‹"; margin-right: 4px; opacity: 0.7; }
/* Nobody on call. Loud while it can still be fixed, quiet once it is history. */
.rota-bar.gap {
background: repeating-linear-gradient(135deg, transparent 0 5px, var(--warn-soft) 5px 10px);
border: 1px dashed var(--warn); color: var(--warn);
}
.rota-bar.gap.past { background: none; border-color: var(--border-strong); color: var(--faint); }
.rota-chip {
display: grid; place-items: center;
@@ -1034,14 +1139,18 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rc5 { background: var(--teal-soft); color: var(--teal); }
.rc6 { background: var(--pink-soft); color: var(--pink); }
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
.rota-note:first-child { margin-top: 0; }
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
.rota-bulk .stacked-form { margin-top: 4px; }
.rota-banner {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-top: 1px solid var(--border); font-size: 14px;
}
.rota-banner .icon { width: 20px; height: 20px; }
.rota-banner.ok { color: var(--ok); }
.rota-banner.warn { color: var(--warn); background: var(--warn-soft); }
.rota-foot {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding: 12px 14px; border-top: 1px solid var(--border);
}
.rota-bulk-form { padding: 0 14px 14px; }
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.ladder-level {
@@ -1054,6 +1163,43 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.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. */
/* The escalation ladder: a numbered step per level, the wait between levels as
its own line, and what happens after the last one at the bottom. */
.ladder { list-style: none; margin: 16px 0 0; padding: 0; }
.step { display: grid; grid-template-columns: 32px minmax(0, 1fr); column-gap: 14px; }
.step-rail { display: flex; flex-direction: column; align-items: center; }
.step-node {
display: grid; place-items: center; flex: none;
width: 32px; height: 32px; border-radius: 50%;
border: 2px solid var(--border-strong); background: var(--surface);
font-size: 14px; font-weight: 750;
}
.step-node.st-ready { border-color: var(--ok); color: var(--ok); }
.step-node.st-escalating { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
.step-node.st-unreachable { border-color: var(--crit); color: var(--crit); background: var(--crit-soft); }
.step-node.end { border-style: dashed; color: var(--muted); }
.step-node.end.warn { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
.step-node .icon { width: 16px; height: 16px; }
.step-line { flex: 1; width: 2px; min-height: 8px; background: var(--border-strong); }
.step-body { min-width: 0; padding-bottom: 4px; }
.step-top { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 32px; }
.step-pages { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; font-size: 14px; }
.step-waiting { margin-top: 6px; font-size: 13px; color: var(--muted); }
.step-waiting a { font-weight: 650; }
.step-gap { display: flex; align-items: center; gap: 8px; padding: 6px 0 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
.step-gap .icon { width: 14px; height: 14px; }
.callout {
display: flex; align-items: flex-start; gap: 12px; margin-top: 4px;
padding: 12px 14px; border: 1px solid var(--warn); border-radius: var(--radius);
background: var(--warn-soft);
}
.callout > .icon { flex: none; width: 20px; height: 20px; margin-top: 1px; color: var(--warn); }
.callout strong { color: var(--warn); }
.callout p { margin: 2px 0 10px; font-size: 14px; }
.ladder-facts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.ladder-facts p { margin: 0; }
.ladder-facts p + p { margin-top: 4px; }
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
@@ -1104,11 +1250,6 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
overflow-x: auto; scrollbar-width: none;
}
.subnav::-webkit-scrollbar { display: none; }
/* A fade on the right edge says "there is more" where the strip overflows;
the desktop width fits every entry, so it is phone-only. */
@media (max-width: 899px) {
.subnav { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
}
.subnav-link {
flex: none;
padding: 8px 12px; margin-bottom: -1px;
+13 -2
View File
@@ -110,6 +110,9 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
<span class="nav-label">Stats</span>
</a>
<!-- Dividers between the groups (Queue, On-call, Alerts, Stats | Team,
Admin | Account). Desktop sidebar only; the phone bar has no room. -->
<span class="nav-sep" aria-hidden="true"></span>
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span>
@@ -121,9 +124,14 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
<span class="nav-label">Admin</span>
</a>
<a class="nav-link nav-link-secondary" href="/more" data-section="more" aria-label="Account">
<span class="nav-sep nav-sep-account" aria-hidden="true"></span>
<!-- At the foot of the desktop sidebar, as the signed-in person: app.js
fills the avatar and the name from /api/me, and until then it reads
"Account". -->
<a class="nav-link nav-link-secondary nav-account" href="/more" data-section="more" aria-label="Account">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
<span class="nav-label">Account</span>
<span class="nav-avatar" id="nav-avatar" aria-hidden="true" hidden></span>
<span class="nav-label" id="nav-account-label">Account</span>
</a>
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same
sheet the old hamburger button did, for the sections the bottom
@@ -146,6 +154,9 @@
<section id="view-queue" class="view view-queue" data-view="queue">
<div class="pane pane-list">
<div class="chips" id="queue-filters" role="tablist" aria-label="Filter"></div>
<!-- The cluster filter. Hidden until the queue has seen two or more
clusters; queue.js fills it in. -->
<div class="queue-origin" id="queue-origin" hidden></div>
<div id="queue-list" class="list"></div>
</div>
<div class="pane pane-detail" id="detail" aria-live="polite"></div>
+3 -2
View File
@@ -13,7 +13,7 @@
// gate.
import * as api from './api.js';
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js';
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
import { state, myID } from './state.js';
const view = () => document.getElementById('view-admin');
@@ -111,6 +111,7 @@ function subnav() {
})));
// On a phone the strip overflows; bring the open section into view so a
// tab past the edge (Sources, Switches) is never the one that is hidden.
fadeOnOverflow(nav);
requestAnimationFrame(() => nav.querySelector('[aria-current]')
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
return nav;
@@ -170,7 +171,7 @@ function teamsCard() {
function newTeamForm() {
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
const form = h('form', { class: 'inline-form' }, name,
h('button', { class: 'btn', type: 'submit', text: 'Create' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
+8 -5
View File
@@ -152,16 +152,19 @@ function identityCard() {
// membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'),
fact('OIDC owner group', t.oidc_owner_group || '—'),
fact('OIDC member group', t.oidc_member_group || notConfigured()),
fact('OIDC owner group', t.oidc_owner_group || notConfigured()),
] : []),
),
form, err, ok,
);
}
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
// value is text or a node.
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
return [h('dt', { text: label }), h('dd', {}, value)];
}
// --- members ---------------------------------------------------------------
@@ -207,7 +210,7 @@ function membersCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
@@ -238,7 +241,7 @@ function invitesCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, role,
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create invite' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
+1 -1
View File
@@ -197,7 +197,7 @@ function teamsCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
+6 -4
View File
@@ -2,8 +2,8 @@
// incident it belongs to, which is where anything can be done about it.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, severityClass, labelSummary } from './format.js';
import { h, clear, badge, severityBadge, originChip, emptyState, spinner } from './ui.js';
import { age, labelSummary, originOf, ORIGIN_LABEL } from './format.js';
const FILTERS = [
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
@@ -77,7 +77,8 @@ function row(a) {
const summary = (a.annotations && a.annotations.summary) || '';
const sev = a.labels && a.labels.severity;
const labels = labelSummary(Object.fromEntries(
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity')));
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity' && k !== ORIGIN_LABEL)));
const origin = originOf(a.labels);
const linked = a.incident_id != null;
return h(linked ? 'a' : 'div', {
class: `row st-${a.status} ${linked ? '' : 'no-link'}`,
@@ -86,8 +87,9 @@ function row(a) {
h('div', { class: 'row-title', text: a.name }),
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
h('div', { class: 'row-meta' },
origin && originChip(origin),
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
sev && badge(sev, `plain ${severityClass(sev)}`),
sev && severityBadge(sev),
summary && h('span', { text: summary }),
labels && h('span', { class: 'labels', text: labels }),
),
+1
View File
@@ -83,6 +83,7 @@ export const setNotifyTarget = (id, ntfyTopic) =>
// incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const clusters = (query) => call('GET', '/incidents/clusters', { query });
export const incident = (id) => call('GET', `/incidents/${id}`);
export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
+13
View File
@@ -16,6 +16,7 @@ import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
import * as device from './device.js';
import { initial } from './format.js';
const $ = (id) => document.getElementById(id);
@@ -467,7 +468,19 @@ export async function signOut() {
showLogin();
}
// The sidebar's Account link shows who is signed in: an avatar and the name,
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
// screen reader still hears "Account".
function renderAccountNav() {
const name = state.me?.user?.username;
if (!name) return;
$('nav-avatar').textContent = initial(name);
$('nav-avatar').hidden = false;
$('nav-account-label').textContent = name;
}
function showApp() {
renderAccountNav();
ssoErrorCode = null;
$('boot').hidden = true;
$('login').hidden = true;
+33
View File
@@ -117,3 +117,36 @@ export function labelSummary(labels, skip = 'alertname') {
export function initial(name) {
return (name || '?').trim().charAt(0) || '?';
}
// Where an incident or alert came from, for a team with several Kubernetes
// clusters (or other origins) behind it: the value of one label, `cluster` by
// convention. It comes from Prometheus's externalLabels, so it is on every
// alert; an incident carries it only when it is in Alertmanager's group_by,
// which is also what keeps two clusters' identical alerts from merging into one
// incident (see the README, "Several clusters, one team").
export const ORIGIN_LABEL = 'cluster';
export function originOf(labels) {
const v = labels && labels[ORIGIN_LABEL];
return v ? String(v) : null;
}
// A stable colour for an origin, from the six-colour rcN palette app.css has,
// so "prod-eu" is the same colour on every row without anything being stored.
export function originClass(value) {
let n = 0;
for (const ch of value) n = (n * 31 + ch.charCodeAt(0)) >>> 0;
return `rc${(n % 6) + 1}`;
}
// The title without its `cluster=...` part, for a row that shows the origin as
// a chip: the server puts every grouping label in the title, so the cluster
// would otherwise appear twice. Anything that does not look like the server's
// "name (k=v, k=v)" shape is returned unchanged.
export function titleWithoutOrigin(title, value) {
if (!value) return title;
const m = title.match(/^(.*?) \((.*)\)$/s);
if (!m) return title;
const rest = m[2].split(', ').filter((p) => p !== `${ORIGIN_LABEL}=${value}`);
return rest.length ? `${m[1]} (${rest.join(', ')})` : m[1];
}
+33 -24
View File
@@ -4,10 +4,10 @@
import * as api from './api.js';
import * as poll from './poll.js';
import {
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js';
import {
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL,
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
} from './format.js';
import { myID, users } from './state.js';
import { back } from './app.js';
@@ -86,7 +86,6 @@ function render() {
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
h('h1', { class: 'detail-title', text: inc.title }),
h('div', { class: 'detail-badges' }, statusBadges()),
quickActions(),
facts(),
groupLabels(),
alertsSection(),
@@ -100,7 +99,9 @@ function render() {
function statusBadges() {
const out = [];
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`));
const origin = originOf(inc.group_labels);
if (origin) out.push(originChip(origin));
if (inc.severity) out.push(severityBadge(inc.severity));
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
@@ -145,7 +146,7 @@ function facts() {
: ackActorLabel() || 'Unassigned';
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))),
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
inc.severity && severityBadge(inc.severity),
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
));
@@ -339,10 +340,30 @@ function timelineSection() {
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
// One icon per kind of event, so the rail says what happened before the text
// does. Anything not listed keeps a plain dot.
const EVENT_ICON = {
triggered: 'bell', notified: 'bell', escalated: 'arrowUp',
acknowledged: 'check', unacknowledged: 'undo', assigned: 'user',
snoozed: 'clock', unsnoozed: 'clock', resolved: 'checkCircle',
alert_added: 'plus', alert_resolved: 'check',
archived: 'archive', unarchived: 'undo',
note: 'note', resolution_note: 'note',
notify_failed: 'alertTriangle', deadman_silent: 'alertTriangle',
};
// An escalation that ran out of levels, or of anywhere to send the page, is
// the one event on the rail that means "nobody is being told": it gets the
// warning treatment instead of reading like any other step.
const isProblem = (ev) => ev.type === 'notify_failed' || ev.type === 'deadman_silent'
|| (ev.type === 'escalated' && /exhausted/i.test(ev.detail || ''));
function timelineItem(ev) {
const mine = isNote(ev) && ev.user_id === myID();
return h('li', { class: `tl-item tl-${ev.type}` },
h('span', { class: 'tl-dot' }),
const problem = isProblem(ev);
const iconName = problem ? 'alertTriangle' : EVENT_ICON[ev.type];
return h('li', { class: `tl-item tl-${ev.type}${problem ? ' tl-problem' : ''}` },
h('span', { class: 'tl-dot' }, iconName && icon(iconName, 'icon tl-icon')),
h('div', { class: 'tl-body' },
h('div', { class: 'tl-text' }, eventText(ev)),
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
@@ -452,9 +473,8 @@ async function copyIncident() {
const isOpen = () => inc.status !== 'resolved';
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
// primaryAction and secondaryAction are factories, not shared nodes — a
// button can only live in one place, and quickActions() below needs its own
// copy of the primary one rather than the actionbar's.
// primaryAction and secondaryAction are factories, not shared nodes: a button
// can only live in one place, and the actionbar is rebuilt on every render.
function primaryAction() {
if (inc.status === 'triggered') {
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
@@ -473,17 +493,7 @@ function secondaryAction() {
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
}
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
}
// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen
// right away, next to the status it responds to. The sticky actionbar below
// keeps carrying every action, primary included, for whenever the page has
// been scrolled past it.
function quickActions() {
const div = h('div', { class: 'detail-quick-actions' }, primaryAction());
if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true;
return div;
return h('button', { class: 'btn', type: 'button', onclick: copyIncident }, icon('copy'), 'Copy');
}
// Desktop has room to show what a phone folds into the More sheet below — see
@@ -494,7 +504,6 @@ 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')
@@ -690,9 +699,9 @@ async function moreMenu() {
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
} else {
// The bar already carries the primary action (Archive/Unarchive) and
// Copy, so the sheet adds only what it lacks.
items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
}
const fn = await openSheet(() => [
+124 -46
View File
@@ -1,9 +1,9 @@
// The incident queue: filter chips and a list of incident rows.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import { h, clear, icon, badge, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, originOf, titleWithoutOrigin } from './format.js';
import { state, myID, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
@@ -30,8 +30,10 @@ onboarding.onRerender(() => renderList());
// The queue used to keep its own team filter (a per-tab sessionStorage value,
// out of step with team.js's own picker); both now defer to the global
// selector's shared state, so re-render whenever it changes.
onTeamChange(() => {
onTeamChange(async () => {
renderChips();
await loadClusters(true);
renderOrigin();
refresh({ fresh: true });
});
@@ -52,6 +54,63 @@ function loadFilter() {
return 'open';
}
// Which cluster the queue is narrowed to, or null for all of them. Kept in
// localStorage like the selected team: it is a standing choice, not a
// per-tab one.
const ORIGIN_KEY = 'terdut.queue.cluster';
let origin = loadOrigin();
let clusters = []; // the clusters the server has seen, for the dropdown
let clustersAt = 0;
function loadOrigin() {
try {
return localStorage.getItem(ORIGIN_KEY) || null;
} catch {
return null; // storage unavailable
}
}
function saveOrigin() {
try {
if (origin) localStorage.setItem(ORIGIN_KEY, origin);
else localStorage.removeItem(ORIGIN_KEY);
} catch {
/* storage unavailable */
}
}
// The clusters to offer, from the server because the list on screen is only
// the fifty most recent. Asked again at most every five minutes; a cluster
// first met in an incident on screen is added at once (see noteClusters). A
// remembered choice the server no longer knows would filter to nothing with no
// hint why, so it is dropped.
async function loadClusters(force = false) {
if (!force && Date.now() - clustersAt < 5 * 60 * 1000) return;
try {
const query = state.selectedTeamID != null ? { team_id: state.selectedTeamID } : undefined;
clusters = await api.clusters(query);
clustersAt = Date.now();
if (origin && !clusters.includes(origin)) {
origin = null;
saveOrigin();
}
} catch {
/* the filter is a convenience: without the list it stays hidden */
}
}
function noteClusters(list) {
let added = false;
for (const inc of list || []) {
const c = originOf(inc.group_labels);
if (c && !clusters.includes(c)) {
clusters = [...clusters, c].sort();
added = true;
}
}
return added;
}
function saveFilter() {
try {
sessionStorage.setItem('terdut.queue.filter', filter);
@@ -64,6 +123,7 @@ export function show(incidentID) {
selected = incidentID;
if (!built) {
renderChips();
renderOrigin();
built = true;
}
renderList();
@@ -73,15 +133,22 @@ export async function refresh({ fresh = false } = {}) {
const f = FILTERS.find((x) => x.id === filter);
const requested = filter;
try {
// First, so a remembered cluster the server no longer knows is dropped
// before it is sent as a filter.
await loadClusters();
// The open list is already fetched for the badges; no need to ask twice.
// The cached open queue covers every team, so it can only be reused when
// no team filter is applied.
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
const query = { ...f.query };
if (state.selectedTeamID != null) query.team_id = state.selectedTeamID;
if (origin) query.cluster = origin;
const cached = filter === 'open' && !fresh && state.selectedTeamID == null && !origin;
const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return;
items = result;
noteClusters(state.open);
noteClusters(items);
error = null;
} catch (err) {
if (requested !== filter) return;
@@ -91,6 +158,7 @@ export async function refresh({ fresh = false } = {}) {
// whichever caller updated it before calling here — app.js's poll, or the
// `cached` branch above.
renderChips();
renderOrigin();
renderList();
}
@@ -109,15 +177,19 @@ function setFilter(id) {
// for the badges — Snoozed/Resolved/Archived would need a request of their
// own, so those chips stay count-less for now.
function chipCount(id) {
const open = state.selectedTeamID == null
? state.open
: state.open.filter((i) => i.team_id === state.selectedTeamID);
const open = state.open.filter((i) =>
(state.selectedTeamID == null || i.team_id === state.selectedTeamID)
&& (!origin || originOf(i.group_labels) === origin));
if (id === 'open') return open.length;
if (id === 'triggered') return open.filter((i) => i.status === 'triggered').length;
if (id === 'acknowledged') return open.filter((i) => i.status === 'acknowledged').length;
return null;
}
// Set the first time the chips are drawn: the strip element is the same one
// for the life of the page, so one scroll listener and one observer will do.
let chipsFade;
function renderChips() {
const el = document.getElementById('queue-filters');
const chips = FILTERS.map((f) => {
@@ -129,42 +201,39 @@ function renderChips() {
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
text: f.label,
}, count != null && h('span', { class: 'count', text: String(count) }));
}, count != null && h('span', { class: count === 0 ? 'count zero' : 'count', text: String(count) }));
});
// Somebody in one team has nothing to choose between, so the row of team
// chips appears only when there is more than one. The default is all of
// them: the combined queue is the point.
if (state.teams.length > 1) {
chips.push(h('span', { class: 'chip-sep' }));
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(state.selectedTeamID == null),
onclick: () => setSelectedTeam(null),
}, h('span', { class: 'team-dot' }), ' All teams'));
for (const team of state.teams) {
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(team.id === state.selectedTeamID),
onclick: () => setSelectedTeam(team.id),
},
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
' ' + team.name,
));
}
}
// A scroll hint for the phone-width row, where the chips can run off the
// right edge with nothing to suggest there's more; the desktop sidebar
// wraps instead of scrolling (see .pane-list .chips), so this fades out
// there via CSS rather than being left out here.
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
// There are no team chips here: the team selector in the sidebar (and in the
// phone's top bar) is the one place the team is chosen, and chips for it
// would be the same choice offered twice.
clear(el, chips);
chipsFade ??= fadeOnOverflow(el);
chipsFade();
}
// The cluster dropdown, under the status chips. Not shown at all until there
// are two clusters to choose between, the same rule the team selector follows.
function renderOrigin() {
const el = document.getElementById('queue-origin');
if (clusters.length < 2) {
el.hidden = true;
return;
}
const select = h('select', { class: 'origin-select', 'aria-label': 'Cluster' },
h('option', { value: '', text: 'All clusters', selected: !origin }),
clusters.map((c) => h('option', { value: c, text: c, selected: c === origin })));
select.addEventListener('change', () => {
origin = select.value || null;
saveOrigin();
items = null;
cursor = -1;
renderChips();
renderList();
refresh({ fresh: true });
});
clear(el, icon('server', 'icon origin-select-icon'), select);
el.hidden = false;
}
function renderList() {
@@ -194,6 +263,10 @@ function row(inc, index) {
const snoozed = isFuture(inc.snoozed_until);
const resolved = inc.status === 'resolved';
// A filter that already says what the status is (Triggered, Resolved,
// Archived) would only have every row repeat it.
const impliedStatus = filter === 'triggered' || filter === 'resolved' || filter === 'archived';
let status;
if (resolved) status = badge('Resolved', 'st-resolved');
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
@@ -214,23 +287,28 @@ function row(inc, index) {
// The team is shown only to somebody who is in more than one. For everybody
// else it is the same word on every row, which is noise rather than
// information.
const team = state.teams.length > 1 && inc.team_name
// ...and not when the queue is already narrowed to one team: that team is
// the same word on every row there too.
const team = state.teams.length > 1 && state.selectedTeamID == null && inc.team_name
? h('span', { class: 'row-team', text: inc.team_name })
: null;
const rowOrigin = originOf(inc.group_labels);
return h('a', {
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
href: `/incidents/${inc.id}`,
'aria-current': inc.id === selected ? 'true' : null,
dataset: { index: String(index) },
},
h('div', { class: 'row-title', text: inc.title }),
h('div', { class: 'row-title', text: titleWithoutOrigin(inc.title, rowOrigin) }),
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
h('div', { class: 'row-meta' },
status,
rowOrigin && !origin && originChip(rowOrigin),
!impliedStatus && status,
// The left-border colour alone doesn't say what it means; spell it out
// too, same badge the incident detail page uses for severity.
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
inc.severity && severityBadge(inc.severity),
assignee,
team,
labels && h('span', { class: 'labels', text: labels }),
+155 -86
View File
@@ -18,7 +18,7 @@
// than no form, but it is not the thing enforcing anything.
import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
@@ -177,6 +177,7 @@ function subnav() {
})));
// On a phone the strip overflows; bring the open section into view so a
// tab past the edge (Sources, Switches) is never the one that is hidden.
fadeOnOverflow(nav);
requestAnimationFrame(() => nav.querySelector('[aria-current]')
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
return nav;
@@ -234,8 +235,8 @@ function overview() {
// A month of it, as a grid. It used to be thirty rows of "date — username",
// which is a rota spelled out one day at a time: the question asked of it is
// "who has which stretch", and thirty names down a column is the one shape
// that answer cannot be read in. So each day carries a coloured initial
// instead, the legend says whose, and a shift becomes a run of one colour.
// that answer cannot be read in. So a shift is drawn as one bar across its
// days with the person's name on it.
//
// The same month laid out the same way as the on-call page's week, because it
// is the same rota — heading and arrows outside the card, days inside it.
@@ -274,22 +275,8 @@ function scheduleCard() {
const today = isoDate(new Date());
const month = monthStart.getMonth();
// Whose colours to explain, in the order the month meets them. Only the days
// of this month count: a name that appears solely in the overhang belongs to
// the month next door and would be explaining a chip nobody asked about.
const seen = new Map();
const cells = [];
for (let i = 0; i < count; i++) {
const d = addDays(start, i);
const key = isoDate(d);
const e = byDate.get(key);
const inMonth = d.getMonth() === month;
// Every row starts with its week number, which is also the way to fill the
// whole week at once.
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
cells.push(dayCell(d, key, e, inMonth, today));
}
const rows = [];
for (let w = 0; w < count / 7; w++) rows.push(weekRow(addDays(start, w * 7), byDate, today, month));
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
for (let i = 0; i < 7; i++) {
@@ -297,38 +284,104 @@ function scheduleCard() {
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
}
// The range form is the way to fill a whole shift at once, but it is not
// what the page is for, so it stays folded away until asked for.
let bulk = null;
if (isOwner()) {
const form = h('div', { class: 'rota-bulk-form', hidden: true }, assignForm());
const toggle = h('button', {
class: 'btn', type: 'button', 'aria-expanded': 'false',
onclick: () => {
form.hidden = !form.hidden;
toggle.setAttribute('aria-expanded', String(!form.hidden));
},
}, icon('calendar'), 'Assign a range of days');
bulk = [
h('div', { class: 'rota-foot' },
h('span', { class: 'muted small', text: 'Tap a day to change who holds it.' }), toggle),
form,
];
}
const thisMonth = isoDate(monthStart) === isoDate(firstOfMonth(new Date()));
return [
h('div', { class: 'page-head' },
h('h2', { text: 'On-call rota' }),
h('div', { class: 'week-nav' },
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
class: 'btn btn-icon week-arrow', type: 'button',
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
}, icon('chevronLeft')),
h('span', { class: 'month-label', text: monthFmt.format(monthStart) }),
h('button', {
class: 'btn btn-ghost label', type: 'button',
title: 'Back to this month',
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
text: monthFmt.format(monthStart),
}),
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
class: 'btn btn-icon week-arrow', type: 'button',
'aria-label': 'Next month', onclick: () => shiftMonth(1),
}, icon('chevronRight')),
h('button', {
class: 'btn btn-sm', type: 'button', text: 'Today', disabled: thisMonth,
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
}),
),
),
h('div', { class: 'card' },
h('div', { class: 'rota-grid' }, heads, cells),
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
// The range form is the way to fill a whole shift at once, but it is not
// what the page is for, so it stays folded away under the month it edits.
isOwner() && h('details', { class: 'rota-bulk' },
h('summary', { text: 'Assign a range of days' }),
assignForm()),
h('div', { class: 'rota-grid' }, h('div', { class: 'rota-heads' }, heads), rows),
coverNote(byDate),
bulk,
),
];
}
// One Monday-to-Sunday row: the week number, a tappable cell per day, and the
// shifts drawn over the days as bars. A bar is a run of consecutive days held
// by the same person, so a week handed to one person is one bar with one name
// on it. The bars ignore the pointer, so a tap lands on the day underneath.
function weekRow(monday, byDate, today, month) {
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
const keys = days.map(isoDate);
const entries = keys.map((k) => byDate.get(k) || null);
const uid = (i) => (entries[i] ? entries[i].user_id : null);
const before = byDate.get(isoDate(addDays(monday, -1)));
const after = byDate.get(isoDate(addDays(monday, 7)));
const cells = [weekCell(monday, byDate, today)];
days.forEach((d, i) => {
const cell = dayCell(d, keys[i], entries[i], d.getMonth() === month, today);
cell.style.gridColumn = String(i + 2);
cells.push(cell);
});
for (let i = 0; i < 7;) {
let j = i;
while (j + 1 < 7 && uid(j + 1) === uid(i)) j++;
const entry = entries[i];
// A flat end says the shift carries on past this row, not that it stops.
const contL = !!entry && i === 0 && before?.user_id === entry.user_id;
const contR = !!entry && j === 6 && after?.user_id === entry.user_id;
cells.push(shiftBar(entry, i, j, { days, keys, month, today, contL, contR }));
i = j + 1;
}
return h('div', { class: 'rota-row' }, cells);
}
function shiftBar(entry, i, j, o) {
const span = j - i + 1;
const outside = o.days.slice(i, j + 1).every((d) => d.getMonth() !== o.month);
const cls = ['rota-bar', entry ? colorClass(entry.user_id) : 'gap',
outside && 'outside', o.keys[j] < o.today && 'past', o.contL && 'cont-l', o.contR && 'cont-r']
.filter(Boolean).join(' ');
// One day is too narrow for a name on a phone; the initial stands in and the
// full name is in the day's own label underneath.
const name = entry
? `${entry.username}${entry.user_id === myID() ? ' (you)' : ''}`
: (span >= 3 ? 'Nobody on call' : '');
const bar = h('div', {
class: cls, 'aria-hidden': 'true', title: name || 'Nobody on call',
text: entry && span === 1 ? initial(entry.username) : name,
});
bar.style.gridColumn = `${i + 2} / span ${span}`;
return bar;
}
// The ISO week number at the start of a row. For an owner it is a button: one
// tap fills the week, which is the way a rota is usually handed out — a person
// takes a week, not seven separate days.
@@ -347,15 +400,10 @@ function weekCell(monday, byDate, today) {
}
function dayCell(d, key, e, inMonth, today) {
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today']
.filter(Boolean).join(' ');
const label = `${key} · ${e ? e.username : 'nobody'}`;
const body = [
h('span', { class: 'rota-num', text: String(d.getDate()) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
: h('span', { class: 'rota-chip none' }),
];
const body = [h('span', { class: 'rota-num', text: String(d.getDate()) })];
// A member sees the same grid without the affordance, the way every other
// control on this page is hidden rather than shown and refused.
return isOwner()
@@ -374,17 +422,7 @@ function colorClass(userID) {
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
}
function legend(seen) {
if (!seen.size) return null;
return h('div', { class: 'rota-legend' },
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
h('span', { text: name }),
id === myID() && h('span', { class: 'you', text: 'you' }),
)));
}
// The gap count, which is the one thing the grid states only by omission. Days
// The gap count, which is the one thing the bars state only by omission. Days
// already past are not counted: an empty Tuesday last week is history, not a
// hole somebody still has to fill.
function coverNote(byDate) {
@@ -395,10 +433,14 @@ function coverNote(byDate) {
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
if (key >= today && !byDate.has(key)) gaps++;
}
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
return h('p', { class: 'rota-note' },
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
' left this month with nobody on call.');
if (gaps === 0) {
return h('div', { class: 'rota-banner ok' }, icon('checkCircle'),
h('span', { text: 'Every day left this month has somebody on call.' }));
}
return h('div', { class: 'rota-banner warn', role: 'status' }, icon('alertTriangle'),
h('span', {},
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
' left this month with nobody on call.'));
}
// One week, in the sheet: who holds each day of it, and one person to put on
@@ -523,7 +565,7 @@ function assignForm() {
// API enforces: a plain assignment that silently moved a shift would move
// who gets paged without telling either of them.
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Assign' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
@@ -567,23 +609,49 @@ function escalationCard() {
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: '—' })),
));
// The ladder as steps, top to bottom: each level, the wait before the next,
// and what happens when the last one is not answered. The wait belongs to the
// level above it ("then after 5m"), which is how the API states it.
const steps = [];
levels.forEach((l) => {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' },
h('span', { class: `step-node st-${l.status}`, text: String(l.position) }),
h('span', { class: 'step-line' })),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' }, h('strong', { text: `Level ${l.position}` }), levelBadge(l.status)),
h('div', { class: 'step-pages' }, ...l.targets.map(targetLine)),
l.waiting?.length > 0 && h('div', { class: 'step-waiting' }, 'Waiting now: ',
...l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])))));
steps.push(h('li', { class: 'step step-wait' },
h('div', { class: 'step-rail' }, h('span', { class: 'step-line' })),
h('div', { class: 'step-gap' }, icon('clock', 'icon'), `then after ${duration(l.timeout_seconds * 1000)}`)));
});
if (levels.length) {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` },
icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' },
h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })),
esc.fallback_topic
? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.')
: h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'),
h('div', {},
h('strong', { text: 'No fallback topic' }),
h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }),
isOwner() && h('button', {
class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }),
}, icon('plus'), 'Add fallback'))))));
}
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(n
? `Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`
: 'Nothing repeats: the ladder runs once.');
facts.push(esc.last_escalated_at
? ['Last escalated ',
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
@@ -595,23 +663,18 @@ function escalationCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Escalation' }),
isOwner() && h('button', {
class: 'btn', type: 'button', onclick: openLadderEditor,
class: 'btn btn-primary', 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('ol', { class: 'ladder' }, steps)
: 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)),
facts.length > 0 && h('div', { class: 'ladder-facts' }, ...facts.map((f) => h('p', { class: 'muted small' }, f))),
);
}
@@ -619,7 +682,7 @@ function escalationCard() {
// 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() {
function openLadderEditor({ focusFallback = false } = {}) {
const esc = data.escalation || {};
const draft = {
repeat_count: esc.repeat_count || 0,
@@ -631,6 +694,7 @@ function openLadderEditor() {
};
const body = h('div', { class: 'ladder-editor' });
let fallbackInput = null;
const problem = h('p', { class: 'load-error', hidden: true });
const paint = () => {
@@ -670,7 +734,7 @@ function openLadderEditor() {
value: String(draft.repeat_count),
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
});
const fallback = h('input', {
const fallback = fallbackInput = h('input', {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; },
});
@@ -701,6 +765,7 @@ function openLadderEditor() {
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
save),
]);
if (focusFallback) requestAnimationFrame(() => fallbackInput?.focus());
}
function targetRow(level, target, index, repaint) {
@@ -758,7 +823,7 @@ function integrationsCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Alert sources' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New source', onclick: openNewSource,
class: 'btn btn-primary', type: 'button', text: 'New source', onclick: openNewSource,
})),
h('p', { class: 'muted small' },
'Alerts arrive on an integration key, which says both that the sender may ',
@@ -980,7 +1045,7 @@ function deadmanCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Dead man’s switches' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New switch', onclick: () => openSwitchForm(),
class: 'btn btn-primary', type: 'button', text: 'New switch', onclick: () => openSwitchForm(),
})),
h('p', { class: 'muted small' },
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
@@ -1132,23 +1197,27 @@ function shiftCell(m) {
function oidcGroupsCard() {
if (!state.auth?.oidc?.enabled) return null;
const g = data.oidcGroups || { member_group: '', owner_group: '' };
// With nothing set, Edit is the one thing to do here, so it leads.
const unset = !g.member_group && !g.owner_group;
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Single sign-on' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
class: unset ? 'btn btn-primary' : 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
})),
h('p', { class: 'muted small' },
'Members of the group below are added to this team automatically at ',
'sign-in; members of the owner group become owners. Leave a field ',
'blank to grant nothing this way.'),
h('dl', { class: 'user-facts' },
fact('Member group', g.member_group || '—'),
fact('Owner group', g.owner_group || '—'),
fact('Member group', g.member_group || notConfigured()),
fact('Owner group', g.owner_group || notConfigured()),
),
);
}
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
@@ -1213,7 +1282,7 @@ function membersCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
class: 'btn btn-primary', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',
+40
View File
@@ -1,5 +1,7 @@
// DOM helpers, the bottom sheet, confirmation and toasts.
import { severityClass, originClass } from './format.js';
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
// attributes; boolean true sets an empty attribute, false/null skips it.
export function h(tag, attrs = {}, ...children) {
@@ -30,8 +32,29 @@ export function clear(el, ...children) {
return el;
}
// Marks a horizontally scrolling strip with data-more while there is more of it
// to the right, which the CSS turns into a fade on that edge. The fade is a
// mask on the strip itself, so it stays put at the edge of the screen: a fade
// drawn as a child of the scroller scrolls away with the content. Returns the
// update function, for a strip whose contents change under it.
export function fadeOnOverflow(el) {
const update = () => {
el.toggleAttribute('data-more', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
el.addEventListener('scroll', update, { passive: true });
if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(el);
update();
return update;
}
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
const ICONS = {
server: ['rect:3,4,18,6,2', 'rect:3,14,18,6,2', 'M7 7h.01', 'M7 17h.01'],
alertTriangle: ['M12 4l9.5 16.5h-19z', 'M12 10v4.5', 'M12 17.5h.01'],
alertCircle: ['circle:12,12,9', 'M12 7.5v5', 'M12 16h.01'],
infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'],
arrowUp: ['M12 19V5', 'M6 11l6-6 6 6'],
plus: ['M12 5v14M5 12h14'],
back: ['M15 18l-6-6 6-6'],
more: ['M5 12h.01M12 12h.01M19 12h.01'],
queueList: ['M4 6h16M4 12h16M4 18h10'],
@@ -171,6 +194,23 @@ export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text });
}
// Where it came from (the cluster): a chip in that origin's colour, with a
// server icon so it reads as a place and not as a status.
export function originChip(value) {
return h('span', { class: `origin-chip ${originClass(value)}`, title: `Origin: ${value}` },
icon('server', 'icon badge-icon'), value);
}
// The severity as a badge with a shape as well as a colour: a triangle for
// critical, a circle with ! for warning, a circle with i for anything else, so
// the coloured bar and badge are not the only thing that says it.
const SEVERITY_ICON = { 'sev-critical': 'alertTriangle', 'sev-warning': 'alertCircle' };
export function severityBadge(severity) {
const cls = severityClass(severity);
return h('span', { class: `badge plain ${cls}` },
icon(SEVERITY_ICON[cls] || 'infoCircle', 'icon badge-icon'), severity);
}
// Access granted by the identity provider's groups. The server refuses to edit
// it by hand (it would be undone at the next sign-in), so the controls that
// would try are disabled with this as their reason.