Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb09525e3 | |||
| c4833067f0 | |||
| 2bc8f336a0 | |||
| a955356821 | |||
| db474ca909 | |||
| a23e88c16d | |||
| def0f68d00 | |||
| fb86a18988 | |||
| b848143471 | |||
| 942517c7a8 | |||
| 7665e5e52f | |||
| bcf1a3e99b | |||
| 3a96b20cbe | |||
| 7b9d309d13 | |||
| 7412456c5a |
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ',
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user