Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db474ca909 | |||
| a23e88c16d | |||
| def0f68d00 | |||
| fb86a18988 | |||
| b848143471 | |||
| 942517c7a8 | |||
| 7665e5e52f | |||
| bcf1a3e99b |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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)) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user