Compare commits

...

8 Commits

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

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

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

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

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

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

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

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

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

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

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:51:50 +02:00
16 changed files with 335 additions and 90 deletions
+19
View File
@@ -444,6 +444,25 @@ 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 an incident that hit `critical` still reads as critical after the critical alert
clears. 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.
### An incident opens only on a new occurrence ### An incident opens only on a new occurrence
An incident opens when an alert **transitions into firing**: a fingerprint that An incident opens when an alert **transitions into firing**: a fingerprint that
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight: # 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 # image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing. # metadata and drives nothing.
version: 0.41.1 version: 0.42.1
appVersion: "v0.41.1" appVersion: "v0.42.1"
+48 -3
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@@ -409,9 +410,11 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID) strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID)
} }
title := pageTitle(inc)
switch n.kind { switch n.kind {
case notifyResolved: case notifyResolved:
msg.Title = "Resolved: " + inc.Title msg.Title = "Resolved: " + title
msg.Message = "All alerts stopped firing after " + msg.Message = "All alerts stopped firing after " +
humanDuration(time.Since(inc.TriggeredAt)) humanDuration(time.Since(inc.TriggeredAt))
msg.Priority = ntfyPriorityLow msg.Priority = ntfyPriorityLow
@@ -419,9 +422,9 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
return msg return msg
case notifyReminder: case notifyReminder:
msg.Title = "Still unacknowledged: " + inc.Title msg.Title = "Still unacknowledged: " + title
default: default:
msg.Title = inc.Title msg.Title = title
} }
severity := derefString(inc.Severity) severity := derefString(inc.Severity)
@@ -443,6 +446,48 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
return msg 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 // ntfy's priority scale. Max is the one that overrides the phone's quiet
// settings, which is the whole point of paging on critical. // settings, which is the whole point of paging on critical.
const ( const (
+75
View File
@@ -1,6 +1,7 @@
package api_test package api_test
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -164,10 +165,84 @@ func fireCritical(t *testing.T, s *ts) {
}, "{}:{alertname=\"DiskFull\"}") }, "{}:{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()
payload := map[string]any{
"version": "4", "status": "firing", "groupKey": groupKey, "groupLabels": groupLabels,
"alerts": []map[string]any{amAlert("fp-grouped", "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 // 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) { func TestNotify_TriggeredIncidentPagesOnCall(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"}) s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
+61 -25
View File
@@ -296,6 +296,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.nav-brand { display: none; } .nav-brand { display: none; }
.nav-sep, .nav-avatar { display: none; }
/* Hidden here (shown from 900px below): on the phone bar the team switcher /* Hidden here (shown from 900px below): on the phone bar the team switcher
lives in the topbar instead, as #team-selector-mobile. */ lives in the topbar instead, as #team-selector-mobile. */
.nav-team-selector { display: none; } .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. */ block below cancels it back to a natural-width row item. */
flex: 1 1 0; flex: 1 1 0;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; 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 /* min-width lets a column shrink below its label's natural width, which is
what stops six tabs widening the bar past the screen. */ what stops six tabs widening the bar past the screen. */
min-width: 0; padding: 0 2px; 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 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); } .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 { .nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px); position: absolute; top: 6px; left: calc(50% + 6px);
min-width: 18px; height: 18px; padding: 0 5px; min-width: 18px; height: 18px; padding: 0 5px;
@@ -389,16 +395,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.chip .count { margin-left: 4px; opacity: 0.7; } .chip .count { margin-left: 4px; opacity: 0.7; }
/* Nothing in it: step back so the chips that have something stand out. */ /* Nothing in it: step back so the chips that have something stand out. */
.chip .count.zero { opacity: 0.4; } .chip .count.zero { opacity: 0.4; }
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling) /* A scrolling strip fades on the right edge while there is more to scroll to
box stays flush with its real right edge regardless of scroll position, (fadeOnOverflow in ui.js sets data-more). A mask on the strip itself, not an
which turned out not to be true of position:sticky here — as a flex element inside it: anything inside a scroller scrolls away with the content,
item, its sticky offset interacted with the row's gap and its own which is what the old overlay did. */
negative margin, landing short of the edge by about one gap's width. */ [data-more] {
.chips-fade { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
position: absolute; top: 0; right: 0; bottom: 0; mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
width: 24px;
background: linear-gradient(to right, transparent, var(--bg));
pointer-events: none;
} }
/* ---------- lists ---------- */ /* ---------- lists ---------- */
@@ -440,7 +443,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 /* 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. */ 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. */ /* 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 { .row-team {
padding: 1px 6px; border-radius: 4px; padding: 1px 6px; border-radius: 4px;
@@ -486,7 +488,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::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.plain::before { display: none; } .badge.plain::before { display: none; }
.badge .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; } .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-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); } .badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); } .badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
@@ -564,11 +574,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-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 { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
.alert-item-foot a { color: var(--accent); font-weight: 600; } .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::-webkit-details-marker { display: none; }
details > summary::before { content: "▸ "; } details > summary::before {
details[open] > summary::before { content: "▾ "; } 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; } 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 /* One .tl-phase per status the incident has been through (see
timelinePhases() in incident.js) — each with its own .timeline <ol>, so timelinePhases() in incident.js) — each with its own .timeline <ol>, so
@@ -827,6 +851,17 @@ kbd {
.nav-team-selector { display: inline-flex; margin: -8px 10px 14px; width: calc(100% - 20px); } .nav-team-selector { display: inline-flex; margin: -8px 10px 14px; width: calc(100% - 20px); }
.nav-link-secondary { display: flex; } .nav-link-secondary { display: flex; }
.nav-more-btn { display: none; } .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 { .nav-link {
flex: none; flex-direction: row; justify-content: flex-start; gap: 12px; flex: none; flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm); min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -834,6 +869,11 @@ kbd {
} }
.nav-link:hover { background: var(--surface-2); } .nav-link:hover { background: var(--surface-2); }
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); } .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-link svg { width: 20px; height: 20px; }
.nav-badge { position: static; margin-left: auto; } .nav-badge { position: static; margin-left: auto; }
@@ -851,8 +891,6 @@ kbd {
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar /* 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. */ is hidden, so the chips wrap here instead: Archived stays reachable. */
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; } .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 /* 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 takes the whole row instead of leaving the second column as dead space
around the placeholder text. Selecting an incident (.has-detail) drops around the placeholder text. Selecting an incident (.has-detail) drops
@@ -901,9 +939,12 @@ kbd {
.admin-table th { .admin-table th {
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px; text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.04em; 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 tr:last-child td { border-bottom: none; }
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; } .admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; } .admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
@@ -1199,11 +1240,6 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
overflow-x: auto; scrollbar-width: none; overflow-x: auto; scrollbar-width: none;
} }
.subnav::-webkit-scrollbar { display: 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 { .subnav-link {
flex: none; flex: none;
padding: 8px 12px; margin-bottom: -1px; padding: 8px 12px; margin-bottom: -1px;
+10 -2
View File
@@ -110,6 +110,9 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg> <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> <span class="nav-label">Stats</span>
</a> </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"> <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> <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> <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> <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> <span class="nav-label">Admin</span>
</a> </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> <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> </a>
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same <!-- Phone-width only (see .nav-more-btn in app.css): opens the same
sheet the old hamburger button did, for the sections the bottom sheet the old hamburger button did, for the sections the bottom
+3 -2
View File
@@ -13,7 +13,7 @@
// gate. // gate.
import * as api from './api.js'; 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'; import { state, myID } from './state.js';
const view = () => document.getElementById('view-admin'); 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 // 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. // tab past the edge (Sources, Switches) is never the one that is hidden.
fadeOnOverflow(nav);
requestAnimationFrame(() => nav.querySelector('[aria-current]') requestAnimationFrame(() => nav.querySelector('[aria-current]')
?.scrollIntoView({ inline: 'center', block: 'nearest' })); ?.scrollIntoView({ inline: 'center', block: 'nearest' }));
return nav; return nav;
@@ -170,7 +171,7 @@ function teamsCard() {
function newTeamForm() { function newTeamForm() {
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true }); const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
const form = h('form', { class: 'inline-form' }, name, 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) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (busy) return; if (busy) return;
+8 -5
View File
@@ -152,16 +152,19 @@ function identityCard() {
// membership looks the way it does, but setting it is the team's own // membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab. // owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [ ...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'), fact('OIDC member group', t.oidc_member_group || notConfigured()),
fact('OIDC owner group', t.oidc_owner_group || '—'), fact('OIDC owner group', t.oidc_owner_group || notConfigured()),
] : []), ] : []),
), ),
form, err, ok, form, err, ok,
); );
} }
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
// value is text or a node.
function fact(label, value) { function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })]; return [h('dt', { text: label }), h('dd', {}, value)];
} }
// --- members --------------------------------------------------------------- // --- members ---------------------------------------------------------------
@@ -207,7 +210,7 @@ function membersCard() {
h('option', { value: 'member', text: 'member' }), h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' })); h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role, 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) => { form.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
act(() => api.addTeamMember(teamID, Number(pick.value), role.value)); act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
@@ -238,7 +241,7 @@ function invitesCard() {
h('option', { value: 'member', text: 'member' }), h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' })); h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, role, 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) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (busy) return; if (busy) return;
+1 -1
View File
@@ -197,7 +197,7 @@ function teamsCard() {
h('option', { value: 'member', text: 'member' }), h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' })); h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role, 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) => { form.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
act(() => api.addTeamMember(Number(pick.value), userID, role.value)); act(() => api.addTeamMember(Number(pick.value), userID, role.value));
+5 -3
View File
@@ -2,8 +2,8 @@
// incident it belongs to, which is where anything can be done about it. // incident it belongs to, which is where anything can be done about it.
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js'; import { h, clear, badge, severityBadge, originChip, emptyState, spinner } from './ui.js';
import { age, labelSummary } from './format.js'; import { age, labelSummary, originOf, ORIGIN_LABEL } from './format.js';
const FILTERS = [ const FILTERS = [
{ id: 'firing', label: 'Firing', query: { status: 'firing' } }, { id: 'firing', label: 'Firing', query: { status: 'firing' } },
@@ -77,7 +77,8 @@ function row(a) {
const summary = (a.annotations && a.annotations.summary) || ''; const summary = (a.annotations && a.annotations.summary) || '';
const sev = a.labels && a.labels.severity; const sev = a.labels && a.labels.severity;
const labels = labelSummary(Object.fromEntries( 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; const linked = a.incident_id != null;
return h(linked ? 'a' : 'div', { return h(linked ? 'a' : 'div', {
class: `row st-${a.status} ${linked ? '' : 'no-link'}`, class: `row st-${a.status} ${linked ? '' : 'no-link'}`,
@@ -86,6 +87,7 @@ function row(a) {
h('div', { class: 'row-title', text: a.name }), 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-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
h('div', { class: 'row-meta' }, h('div', { class: 'row-meta' },
origin && originChip(origin),
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`), badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
sev && severityBadge(sev), sev && severityBadge(sev),
summary && h('span', { text: summary }), summary && h('span', { text: summary }),
+13
View File
@@ -16,6 +16,7 @@ import * as admin from './admin.js';
import * as adminuser from './adminuser.js'; import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js'; import * as adminteam from './adminteam.js';
import * as device from './device.js'; import * as device from './device.js';
import { initial } from './format.js';
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -467,7 +468,19 @@ export async function signOut() {
showLogin(); 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() { function showApp() {
renderAccountNav();
ssoErrorCode = null; ssoErrorCode = null;
$('boot').hidden = true; $('boot').hidden = true;
$('login').hidden = true; $('login').hidden = true;
+33
View File
@@ -117,3 +117,36 @@ export function labelSummary(labels, skip = 'alertname') {
export function initial(name) { export function initial(name) {
return (name || '?').trim().charAt(0) || '?'; 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 -2
View File
@@ -4,10 +4,10 @@
import * as api from './api.js'; import * as api from './api.js';
import * as poll from './poll.js'; import * as poll from './poll.js';
import { import {
h, clear, icon, badge, severityBadge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState, h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js'; } from './ui.js';
import { import {
ago, when, until, duration, isFuture, STATUS_LABEL, ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
} from './format.js'; } from './format.js';
import { myID, users } from './state.js'; import { myID, users } from './state.js';
import { back } from './app.js'; import { back } from './app.js';
@@ -99,6 +99,8 @@ function render() {
function statusBadges() { function statusBadges() {
const out = []; const out = [];
const origin = originOf(inc.group_labels);
if (origin) out.push(originChip(origin));
if (inc.severity) out.push(severityBadge(inc.severity)); if (inc.severity) out.push(severityBadge(inc.severity));
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`)); out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) { if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
+16 -36
View File
@@ -1,9 +1,9 @@
// The incident queue: filter chips and a list of incident rows. // The incident queue: filter chips and a list of incident rows.
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js'; import { h, clear, badge, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js'; import { ago, until, isFuture, severityClass, labelSummary, originOf, titleWithoutOrigin } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js'; import { state, myID, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js'; import * as onboarding from './onboarding.js';
import { navigate } from './app.js'; import { navigate } from './app.js';
@@ -118,6 +118,10 @@ function chipCount(id) {
return null; 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() { function renderChips() {
const el = document.getElementById('queue-filters'); const el = document.getElementById('queue-filters');
const chips = FILTERS.map((f) => { const chips = FILTERS.map((f) => {
@@ -132,39 +136,12 @@ function renderChips() {
}, count != null && h('span', { class: count === 0 ? 'count zero' : '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 // There are no team chips here: the team selector in the sidebar (and in the
// chips appears only when there is more than one. The default is all of // phone's top bar) is the one place the team is chosen, and chips for it
// them: the combined queue is the point. // would be the same choice offered twice.
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' }));
clear(el, chips); clear(el, chips);
chipsFade ??= fadeOnOverflow(el);
chipsFade();
} }
function renderList() { function renderList() {
@@ -224,15 +201,18 @@ function row(inc, index) {
? h('span', { class: 'row-team', text: inc.team_name }) ? h('span', { class: 'row-team', text: inc.team_name })
: null; : null;
const origin = originOf(inc.group_labels);
return h('a', { return h('a', {
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`, class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
href: `/incidents/${inc.id}`, href: `/incidents/${inc.id}`,
'aria-current': inc.id === selected ? 'true' : null, 'aria-current': inc.id === selected ? 'true' : null,
dataset: { index: String(index) }, dataset: { index: String(index) },
}, },
h('div', { class: 'row-title', text: inc.title }), h('div', { class: 'row-title', text: titleWithoutOrigin(inc.title, origin) }),
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }), h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
h('div', { class: 'row-meta' }, h('div', { class: 'row-meta' },
origin && originChip(origin),
!impliedStatus && status, !impliedStatus && status,
// The left-border colour alone doesn't say what it means; spell it out // The left-border colour alone doesn't say what it means; spell it out
// too, same badge the incident detail page uses for severity. // too, same badge the incident detail page uses for severity.
+13 -8
View File
@@ -18,7 +18,7 @@
// than no form, but it is not the thing enforcing anything. // than no form, but it is not the thing enforcing anything.
import * as api from './api.js'; 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 { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.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 // 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. // tab past the edge (Sources, Switches) is never the one that is hidden.
fadeOnOverflow(nav);
requestAnimationFrame(() => nav.querySelector('[aria-current]') requestAnimationFrame(() => nav.querySelector('[aria-current]')
?.scrollIntoView({ inline: 'center', block: 'nearest' })); ?.scrollIntoView({ inline: 'center', block: 'nearest' }));
return nav; return nav;
@@ -564,7 +565,7 @@ function assignForm() {
// API enforces: a plain assignment that silently moved a shift would move // API enforces: a plain assignment that silently moved a shift would move
// who gets paged without telling either of them. // who gets paged without telling either of them.
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'), 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) => { form.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
@@ -822,7 +823,7 @@ function integrationsCard() {
h('div', { class: 'card-head' }, h('div', { class: 'card-head' },
h('h2', { text: 'Alert sources' }), h('h2', { text: 'Alert sources' }),
isOwner() && h('button', { 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' }, h('p', { class: 'muted small' },
'Alerts arrive on an integration key, which says both that the sender may ', 'Alerts arrive on an integration key, which says both that the sender may ',
@@ -1044,7 +1045,7 @@ function deadmanCard() {
h('div', { class: 'card-head' }, h('div', { class: 'card-head' },
h('h2', { text: 'Dead man’s switches' }), h('h2', { text: 'Dead man’s switches' }),
isOwner() && h('button', { 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' }, h('p', { class: 'muted small' },
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ', 'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
@@ -1196,23 +1197,27 @@ function shiftCell(m) {
function oidcGroupsCard() { function oidcGroupsCard() {
if (!state.auth?.oidc?.enabled) return null; if (!state.auth?.oidc?.enabled) return null;
const g = data.oidcGroups || { member_group: '', owner_group: '' }; 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' }, return h('div', { class: 'card' },
h('div', { class: 'card-head' }, h('div', { class: 'card-head' },
h('h2', { text: 'Single sign-on' }), h('h2', { text: 'Single sign-on' }),
isOwner() && h('button', { 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' }, h('p', { class: 'muted small' },
'Members of the group below are added to this team automatically at ', 'Members of the group below are added to this team automatically at ',
'sign-in; members of the owner group become owners. Leave a field ', 'sign-in; members of the owner group become owners. Leave a field ',
'blank to grant nothing this way.'), 'blank to grant nothing this way.'),
h('dl', { class: 'user-facts' }, h('dl', { class: 'user-facts' },
fact('Member group', g.member_group || '—'), fact('Member group', g.member_group || notConfigured()),
fact('Owner group', g.owner_group || '—'), fact('Owner group', g.owner_group || notConfigured()),
), ),
); );
} }
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
function fact(label, value) { function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })]; return [h('dt', { text: label }), h('dd', { text: value })];
} }
@@ -1277,7 +1282,7 @@ function membersCard() {
h('div', { class: 'card-head' }, h('div', { class: 'card-head' },
h('h2', { text: 'Members' }), h('h2', { text: 'Members' }),
isOwner() && h('button', { 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' }, h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ', 'Owners set up the team; members work its incidents. Somebody who can’t be ',
+24 -1
View File
@@ -1,6 +1,6 @@
// DOM helpers, the bottom sheet, confirmation and toasts. // DOM helpers, the bottom sheet, confirmation and toasts.
import { severityClass } from './format.js'; import { severityClass, originClass } from './format.js';
// h builds an element. attrs: class, text, on<event>, dataset, aria/other // h builds an element. attrs: class, text, on<event>, dataset, aria/other
// attributes; boolean true sets an empty attribute, false/null skips it. // attributes; boolean true sets an empty attribute, false/null skips it.
@@ -32,8 +32,24 @@ export function clear(el, ...children) {
return el; 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. // Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
const ICONS = { 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'], 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'], alertCircle: ['circle:12,12,9', 'M12 7.5v5', 'M12 16h.01'],
infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'], infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'],
@@ -178,6 +194,13 @@ export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text }); 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 // 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 // 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. // the coloured bar and badge are not the only thing that says it.