Web: visual design pass (issue #26)
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 18s
CI / test (pull_request) Successful in 4m49s

Addresses the screenshot-review feedback in #26. No framework or build
step added — all of this stays within the existing plain HTML/CSS/
vanilla-JS + go:embed architecture.

- Nav: re-enable the bottom tab bar that was already built and
  switched off (Queue/On-call/Alerts/Team + a "More" sheet for
  Stats/Admin/Account), replacing the hamburger on phone width.
- Queue: chip counts, a scroll fade on the filter row, a
  "Triggered Xh ago" + severity label per row, a chevron on the team
  switcher so it reads as a dropdown.
- On-call: collapse repeated same-person days into shift bars (week
  view and "your shifts" both), show the week as a date range with
  the ISO week number as secondary text, split "Current shift" out
  from "Next shifts" with "ends in Nd", a pill badge + row highlight
  for "you".
- Incident detail: fix the actual bug behind the duplicate
  "acknowledged" timeline entries (acknowledgeIncident's UPDATE had no
  guard on the incident's current status, so acknowledging an
  already-acknowledged incident silently re-logged the event — now
  idempotent, with regression tests on both the authenticated route
  and the ntfy ack-button route). Relabel escalation re-pages so they
  don't look like the same page landing twice. Copy the primary action
  up near the top. Label the "···" button. Group the timeline by
  phase (triggered/acknowledged/resolved). Add an "at a glance"
  summary row (duration/severity/responsible) and collapse the group
  labels by default.
- Team overview: reword the vague copy ("One owner." etc.) into plain
  labels.
- Empty states: fill in missing icons/one-liners across queue,
  alerts, stats and the incident timeline.
- CSS: fix card padding bugs, verify link contrast already passes AA,
  introduce a --fs-* type-scale token set and migrate the few
  genuinely isolated cases onto it (left sizes tied to a fixed shape,
  a deliberately prominent display, or a non-negotiable constraint
  like the iOS-zoom-prevention input size as documented exceptions
  rather than guess at a render this change can't see).

Verified with the full fmt/lint/test/helm-lint gate, plus a live
instance against the test DB with seeded incidents and schedule data
to trace the on-call grouping and timeline phase-splitting logic
against real API responses.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Niklas Ye
2026-10-03 09:01:54 +02:00
parent 4358e84b24
commit 91f03c21e8
16 changed files with 452 additions and 118 deletions
+7 -4
View File
@@ -261,14 +261,17 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
}
// acknowledgeIncident records that userID has picked an incident up, and reports
// whether it changed anything — an already-resolved incident is left alone.
// Shared by the authenticated handler and the Acknowledge button in a push
// notification, so both write the same state and the same timeline entry.
// whether it changed anything — an already-resolved or already-acknowledged
// incident is left alone, so a second acknowledge (a retried request, or a
// stale push notification tapped after the web UI already acked it) is a
// no-op rather than a second "acknowledged" timeline entry. Shared by the
// authenticated handler and the Acknowledge button in a push notification,
// so both write the same state and the same timeline entry.
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
res, err := q.ExecContext(ctx, `
UPDATE incidents
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2
WHERE id = $3 AND resolved_at IS NULL`,
WHERE id = $3 AND status = 'triggered'`,
userID, time.Now().Unix(), incidentID)
if err != nil {
return false, err
+11 -1
View File
@@ -197,12 +197,22 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
return
}
if !acked {
if !incidentExists(w, r, db, id) {
// incidentIDParam above already confirmed the incident exists, so this
// is either resolved, or already acknowledged — the latter is now a
// no-op rather than an error, since the caller's desired state
// (acknowledged) already holds.
inc, err := fetchIncident(r.Context(), db, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if inc.Status == "resolved" {
respond(w, http.StatusConflict, errResp("incident is resolved"))
return
}
respond(w, http.StatusOK, inc)
return
}
respondIncident(w, r, db, id)
}
}
+42
View File
@@ -338,6 +338,48 @@ func TestIncident_Acknowledge(t *testing.T) {
}
}
// A second acknowledge — a retried request, or a stale push notification
// tapped after the web UI already acked it — must be a no-op: same state,
// no second "acknowledged" timeline entry. Regression test for the bug
// described in issue #26 ("two acknowledged entries look like a bug").
func TestIncident_AcknowledgeTwiceIsIdempotent(t *testing.T) {
s := newTS(t)
postWebhook(t, s, []map[string]any{
amAlert("fp-ack2", "Y", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
resp := s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("first acknowledge returned %d", resp.StatusCode)
}
var first map[string]any
decode(t, resp, &first)
resp = s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("second acknowledge returned %d, want 200 (idempotent)", resp.StatusCode)
}
var second map[string]any
decode(t, resp, &second)
if second["status"] != "acknowledged" {
t.Errorf("expected status still acknowledged, got %v", second["status"])
}
if second["acknowledged_by"] != first["acknowledged_by"] {
t.Errorf("expected the same acknowledged_by, got %v then %v", first["acknowledged_by"], second["acknowledged_by"])
}
types := eventTypes(timeline(t, s, 1))
n := 0
for _, ty := range types {
if ty == "acknowledged" {
n++
}
}
if n != 1 {
t.Errorf("expected exactly one acknowledged event, got %d in %v", n, types)
}
}
func TestIncident_ManualResolveIsTerminal(t *testing.T) {
s := newTS(t)
postWebhook(t, s, []map[string]any{
+11 -4
View File
@@ -66,12 +66,19 @@ func handleNotifyAck(db *sql.DB) http.HandlerFunc {
return
}
if !acked {
// The incident closed between the page and the tap. Nothing to do,
// and nothing the responder did wrong — report the state, not an error,
// so ntfy shows a success toast rather than a failure.
// Either the incident closed between the page and the tap, or it was
// already acknowledged (e.g. from the web UI, or an earlier tap of
// the same button) — either way nothing the responder did wrong, so
// report the actual state rather than assuming "resolved", and let
// ntfy show a success toast rather than a failure.
inc, err := fetchIncident(r.Context(), db, incidentID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, map[string]any{
"incident_id": incidentID,
"status": "resolved",
"status": inc.Status,
})
return
}
+41
View File
@@ -408,6 +408,47 @@ func TestNotify_AckButtonAcknowledgesIncident(t *testing.T) {
}
}
// The ack token isn't single-use (it stays valid for a day, in case the
// first tap never reaches the server), so tapping the same notification's
// Acknowledge button twice is a real scenario, not just a retried request.
// It must report the incident's actual state, not assume "resolved" —
// see handleNotifyAck's !acked branch — and must not log a second
// "acknowledged" event.
func TestNotify_AckButtonTwiceIsIdempotent(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
fireCritical(t, s)
s.sweepNotify(t)
ackURL := f.messages()[0].Actions[0].URL
path := ackURL[strings.Index(ackURL, "/api/notify/ack/"):]
for i := range 2 {
resp, err := http.Post(s.URL+path, "application/json", nil)
if err != nil {
t.Fatalf("ack %d: %v", i+1, err)
}
var body map[string]any
decode(t, resp, &body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ack %d returned %d", i+1, resp.StatusCode)
}
if body["status"] != "acknowledged" {
t.Errorf("ack %d: expected status acknowledged, got %v", i+1, body["status"])
}
}
n := 0
for _, ty := range eventTypes(timeline(t, s, 1)) {
if ty == "acknowledged" {
n++
}
}
if n != 1 {
t.Errorf("expected exactly one acknowledged event after two taps, got %d", n)
}
}
func TestNotify_AckRejectsUnknownToken(t *testing.T) {
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
fireCritical(t, s)
+85 -19
View File
@@ -47,12 +47,27 @@
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* A ~4-size type scale, per issue #26's "use one type scale" ask. This
file still has a dozen one-off font-size values below; migrating the
low-risk, purely cosmetic ones (standalone titles with no dimensional
or functional constraint) onto these tokens is a start, not the whole
job — the rest (sizes tied to a fixed shape like the avatar circle, to
a deliberately prominent display like a stat tile or the on-call name,
or to a non-negotiable constraint like the 16px that stops iOS zooming
into an input) stay as either their own pixel value or a documented
exception, since guessing at those without seeing them render risks
trading one inconsistency for a worse one. */
--fs-xs: 12px;
--fs-sm: 13px;
--fs-base: 14px;
--fs-lg: 18px;
--fs-xl: 21px;
--topbar-h: 52px;
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
variable rather than deleted since .view, .toast and .nav's own height
calc still read it. */
--tabbar-h: 0px;
/* The phone-width bottom tab bar's height. Unused above 900px: the
desktop block overrides .nav/.view/.toast directly rather than reading
this back down to 0. */
--tabbar-h: 58px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
@@ -212,9 +227,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
#menu-btn { position: relative; }
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
.open-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px; border-radius: 999px;
@@ -226,11 +238,11 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.open-pill.has-triggered::before { background: var(--crit); }
.open-pill.all-acked::before { background: var(--warn); }
/* Hidden on phones — mobile navigates through the hamburger menu in the
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
left sidebar from 900px, where the desktop block below redeclares display. */
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
left sidebar from 900px, where the desktop block below redeclares display
and shows every section flat, .nav-link-secondary included. */
.nav {
display: none;
display: flex;
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
height: calc(var(--tabbar-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
@@ -240,8 +252,15 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
border-top: 1px solid var(--border);
}
.nav-brand { display: none; }
/* Hidden here (shown from 900px below): on the phone bar the team switcher
lives in the topbar instead, as #team-selector-mobile. */
.nav-team-selector { display: none; }
.nav-link-secondary { display: none; }
.nav-link {
position: relative;
/* flex: 1 spreads the tabs evenly across the bar's width; the desktop
block below cancels it back to a natural-width row item. */
flex: 1 1 0;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600;
/* min-width lets a column shrink below its label's natural width, which is
@@ -276,6 +295,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
}
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
/* A team's identity colour — not a status, so never the severity palette. Six
colours, then they repeat; teamColorClass() in format.js picks one by the
@@ -318,6 +338,14 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
}
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
.chip .count { margin-left: 4px; opacity: 0.7; }
/* Pinned to the visible right edge of the scrolling row (sticky, not
absolute, so it tracks the scroll position rather than the content). */
.chips-fade {
position: sticky; right: -1px; flex: none;
width: 24px; margin-left: -24px;
background: linear-gradient(to right, transparent, var(--bg));
pointer-events: none;
}
/* ---------- lists ---------- */
@@ -381,7 +409,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.empty {
padding: 48px 16px; text-align: center; color: var(--muted);
}
.empty strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 4px; }
.empty strong { display: block; color: var(--text); font-size: var(--fs-lg); margin-bottom: 4px; }
.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; }
.load-error {
@@ -434,8 +462,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.detail-head .copy { margin-left: auto; }
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
/* A copy of the sticky actionbar's primary button, right under the status
it responds to — see quickActions() in incident.js. */
.detail-quick-actions { margin-bottom: 14px; }
.detail-quick-actions .btn-primary { font-size: 16px; min-height: 44px; }
.card {
background: var(--surface);
@@ -450,6 +482,9 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.facts dt { color: var(--muted); }
.facts dd { margin: 0; overflow-wrap: anywhere; }
.facts .sub { color: var(--faint); }
.facts dd.fact-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.fact-chip { display: inline-flex; align-items: center; gap: 4px; }
.fact-icon { width: 15px; height: 15px; color: var(--faint); }
.section { margin-top: 22px; }
.section-title {
@@ -481,6 +516,17 @@ details > summary::before { content: "▸ "; }
details[open] > summary::before { content: "▾ "; }
details[open] > summary { margin-bottom: 8px; }
/* One .tl-phase per status the incident has been through (see
timelinePhases() in incident.js) — each with its own .timeline <ol>, so
the existing :first-child/:last-child rail-capping below gives each phase
its own self-contained connecting line rather than one running through
the headings. */
.tl-phase + .tl-phase { border-top: 1px solid var(--border); }
.tl-phase-title {
padding: 10px 14px 0;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--faint);
}
.timeline { list-style: none; margin: 0; padding: 4px 0; }
.tl-item {
position: relative;
@@ -513,7 +559,7 @@ details[open] > summary { margin-bottom: 8px; }
}
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
.similar { list-style: none; margin: 0; padding: 0; }
.similar-item { padding: 10px 0; }
.similar-item { padding: 10px 14px; }
.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); }
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
.note-actions { display: flex; justify-content: flex-end; }
@@ -552,7 +598,7 @@ details[open] > summary { margin-bottom: 8px; }
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); }
.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); }
.sheet-title { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
.sheet-title { font-size: var(--fs-lg); font-weight: 700; margin: 0 0 4px; }
.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; }
.sheet-form { display: grid; gap: 12px; }
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
@@ -604,9 +650,18 @@ details[open] > summary { margin-bottom: 8px; }
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
.now-name { font-size: 20px; font-weight: 750; }
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
is easier to spot there than this plain accent-coloured text. */
.you-badge { margin-left: 6px; }
.week-nav { display: flex; align-items: center; gap: 4px; }
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
/* The week-nav button that shows the date range, "28 Sep – 4 Oct", with the
ISO week number as secondary text inside it — a separate class from
.label above (team.js's month-nav uses that one) so its <small> isn't
caught by the unrelated .label > span styling meant for label chips. */
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
.days { list-style: none; margin: 0; padding: 0; }
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
.day + .day { border-top: 1px solid var(--border); }
@@ -614,11 +669,18 @@ details[open] > summary { margin-bottom: 8px; }
.day-date { color: var(--faint); font-size: 13px; }
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.day-who.nobody { color: var(--faint); font-style: italic; }
/* A run of several days held by the same person (or left empty), replacing
what used to be one identical row per day — see weekRuns() in oncall.js. */
.day.range { grid-template-columns: 1fr auto; }
.day-range { font-weight: 650; }
.day.today { background: var(--accent-soft); }
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
.day.today .day-name { color: var(--accent); }
.day.today .day-name, .day.today .day-range { color: var(--accent); }
.day.past { opacity: 0.6; }
/* Highlights whichever row is yours, same soft tint as .today — they already
read fine layered (today's own row is almost always one of yours too). */
.day.mine { background: var(--accent-soft); }
.shift-list { list-style: none; margin: 0; padding: 0; }
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
@@ -675,9 +737,11 @@ kbd {
display: flex; align-items: center; gap: 10px;
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
}
.nav-team-selector { 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-more-btn { display: none; }
.nav-link {
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);
color: var(--muted); font-size: 14px;
}
@@ -701,6 +765,7 @@ kbd {
is hidden, so the chips wrap here instead: Archived stays reachable. */
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
.pane-list .chip-sep { display: none; }
.chips-fade { display: none; }
.view-queue:not(.has-detail) .pane-detail { display: block; }
/* On desktop the list stays visible next to the detail. */
@@ -966,6 +1031,7 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.overview-head { display: flex; align-items: baseline; gap: 8px; }
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
.overview-item p { margin: 4px 0 0; }
.overview-note-icon { width: 13px; height: 13px; vertical-align: -2px; color: var(--ok); }
/* ---------- stats page ---------- */
+14 -7
View File
@@ -102,7 +102,10 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
<span class="nav-label">Alerts</span>
</a>
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
<!-- Secondary: full-width in the desktop sidebar, folded into the
"More" tab's sheet on the phone-width bottom bar instead (see
.nav-link-secondary in app.css and openNavMenu in app.js). -->
<a class="nav-link nav-link-secondary" href="/stats" data-section="stats" aria-label="Stats">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
<span class="nav-label">Stats</span>
</a>
@@ -113,22 +116,26 @@
<!-- Hidden unless the signed-in user is a system administrator; app.js
unhides it once /api/me says so. The server refuses every admin
endpoint regardless, so this is a courtesy and not a gate. -->
<a class="nav-link" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
<a class="nav-link nav-link-secondary" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
<span class="nav-label">Admin</span>
</a>
<a class="nav-link" href="/more" data-section="more" aria-label="Account">
<a class="nav-link nav-link-secondary" href="/more" data-section="more" aria-label="Account">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
<span class="nav-label">Account</span>
</a>
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same
sheet the old hamburger button did, for the sections the bottom
bar has no room for. Not shown on the desktop sidebar, which lists
every section already. -->
<button class="nav-link nav-more-btn" id="nav-more-btn" type="button" aria-label="More sections" aria-haspopup="menu">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h.01M12 12h.01M19 12h.01"/></svg>
<span class="nav-label">More</span>
</button>
</nav>
<header class="topbar">
<div class="topbar-left">
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
</button>
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
<h1 class="topbar-title" id="topbar-title">Queue</h1>
</div>
+5 -1
View File
@@ -60,7 +60,11 @@ function render() {
let body;
if (error && !items) body = h('div', { class: 'load-error', text: error });
else if (!items) body = spinner();
else if (!items.length) body = emptyState(filter === 'firing' ? 'Nothing firing' : 'No alerts', '', filter === 'firing' ? 'checkCircle' : null);
else if (!items.length) {
body = filter === 'firing'
? emptyState('Nothing firing', 'No alerts are currently firing.', 'checkCircle')
: emptyState('No alerts', 'None match this filter.', 'bell');
}
else {
body = h('div', { class: 'list' },
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
+16 -15
View File
@@ -36,16 +36,17 @@ const SECTIONS = {
device: { title: 'Sign in a terminal', view: device },
};
// The mobile hamburger menu's contents — the same sections the desktop
// sidebar's .nav-link list carries in index.html, in the same order.
// The desktop sidebar's .nav-link list in index.html, in the same order.
// `secondary` marks the ones that fold into the phone bottom bar's "More"
// sheet (openNavMenu below) instead of getting a tab of their own there.
const NAV_ITEMS = [
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart', secondary: true },
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true, secondary: true },
{ path: '/more', section: 'more', label: 'Account', icon: 'user', secondary: true },
];
function parseRoute(pathname) {
@@ -152,15 +153,16 @@ function render() {
// ---------- nav menu ----------
// The mobile hamburger menu: same shape as the sheet-based action menus in
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
// The phone bottom bar's "More" sheet: same shape as the sheet-based action
// menus in incident.js (openSheet + a <ul class="menu"> of menu-item
// buttons), one item per secondary NAV_ITEMS entry — the ones the bar itself
// has no room for, since Queue/On-call/Alerts/Team already have their own
// tab and don't need to be reachable here too.
function openNavMenu() {
const current = SECTIONS[route.section].nav || route.section;
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
const items = NAV_ITEMS.filter((n) => n.secondary && (!n.adminOnly || state.me?.user?.is_admin));
ui.openSheet(() => [
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
ui.h('h2', { class: 'sheet-title', text: 'More' }),
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
ui.h('li', {}, ui.h('button', {
class: 'menu-item',
@@ -171,7 +173,6 @@ function openNavMenu() {
},
ui.icon(n.icon),
n.label,
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
))),
),
]).then((path) => {
@@ -205,8 +206,8 @@ function updateBadges() {
pill.classList.toggle('has-triggered', triggered > 0);
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
// hamburger button (mobile) — only one of the two is ever visible at once.
// Just the Queue tab's own badge now — phone bottom bar and desktop
// sidebar both read the same [data-badge] span on that one nav-link.
for (const badge of document.querySelectorAll('[data-badge]')) {
badge.hidden = triggered === 0;
badge.textContent = String(triggered);
@@ -230,7 +231,7 @@ async function boot() {
document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu);
$('nav-more-btn').addEventListener('click', openNavMenu);
teamselector.init();
ssoErrorCode = takeSSOError();
+88 -18
View File
@@ -7,7 +7,7 @@ import {
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js';
import {
ago, when, until, isFuture, severityClass, STATUS_LABEL,
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL,
} from './format.js';
import { myID, users } from './state.js';
import { back } from './app.js';
@@ -86,6 +86,7 @@ function render() {
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
h('h1', { class: 'detail-title', text: inc.title }),
h('div', { class: 'detail-badges' }, statusBadges()),
quickActions(),
facts(),
groupLabels(),
alertsSection(),
@@ -125,6 +126,20 @@ function who(id, name) {
function facts() {
const rows = [];
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
// Duration, severity and who's on it, in one scannable row up top — the
// rest of this card has each of those too, but spread across rows that
// take reading top to bottom to piece together.
const elapsedTo = inc.resolved_at ? Date.parse(inc.resolved_at) : Date.now();
const responsible = inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to)
: inc.acknowledged_by_id != null ? who(inc.acknowledged_by_id, inc.acknowledged_by)
: 'Unassigned';
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
));
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
if (inc.acknowledged_at) {
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
@@ -148,9 +163,12 @@ function facts() {
function groupLabels() {
const entries = Object.entries(inc.group_labels || {});
if (!entries.length) return null;
// Collapsed by default, the same disclosure alertItem() below uses for an
// alert's own labels — this is background, not something to scan past.
return h('section', { class: 'section' },
h('h2', { class: 'section-title', text: 'Grouped by' }),
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))),
h('details', {},
h('summary', { text: `Grouped by (${entries.length})` }),
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v)))),
);
}
@@ -206,12 +224,20 @@ function eventText(ev, named = false) {
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
case 'unsnoozed': return [strong(person), ' ended the snooze'];
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
// Falls through to the generic `${ev.type}: ${ev.detail}` below otherwise
// — this just capitalises it and drops the redundant "escalated:" prefix
// from detail (already "level 2: alice, bob" or "escalation exhausted: …").
case 'escalated': return [`Escalated — ${ev.detail}`];
case 'note': return [strong(person), ' added a note'];
case 'resolution_note': return [strong(person), ' noted what fixed it'];
case 'notified': {
const to = person ? strong(person) : 'the fallback topic';
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
if (ev.detail === 'resolved') return ['Resolution sent to ', to];
// 'escalated' is a second, later page — the next level firing, not the
// same page landing twice — so it reads as a bug unless told apart
// from the initial 'triggered' page below.
if (ev.detail === 'escalated') return ['Escalation paged ', to];
return ['Paged ', to];
}
case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : ''];
@@ -236,8 +262,34 @@ function similarSection() {
);
}
// Splits the already-sorted timeline on the status transitions that matter —
// first acknowledged, then resolved — so a long incident reads as "before
// anyone had it" / "while someone did" / "after it closed" instead of one
// undifferentiated list. A later re-acknowledge (after an unacknowledge)
// doesn't open a second "Acknowledged" phase; it's still the same spell of
// somebody owning it.
function timelinePhases(sorted) {
const phases = [{ label: 'Triggered', events: [] }];
let acked = false;
for (const ev of sorted) {
if (ev.type === 'acknowledged' && !acked) {
phases.push({ label: 'Acknowledged', events: [] });
acked = true;
} else if (ev.type === 'resolved') {
phases.push({ label: 'Resolved', events: [] });
}
phases[phases.length - 1].events.push(ev);
}
return phases.filter((p) => p.events.length);
}
function timelineSection() {
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
const phases = timelinePhases(sorted);
// A single phase (the common case: most incidents are acked once and
// resolved) names nothing extra — only a split timeline needs the
// headings to make sense of.
const named = phases.length > 1;
return h('section', { class: 'section' },
h('h2', { class: 'section-title' },
h('span', { text: 'Timeline' }),
@@ -245,8 +297,10 @@ function timelineSection() {
icon('note'), 'Add note')),
h('div', { class: 'card' },
sorted.length
? h('ol', { class: 'timeline' }, sorted.map(timelineItem))
: emptyState('No events yet', '')),
? phases.map((p) => h('div', { class: 'tl-phase' },
named && h('div', { class: 'tl-phase-title', text: p.label }),
h('ol', { class: 'timeline' }, p.events.map(timelineItem))))
: emptyState('No events yet', 'Nothing has happened on this incident yet.', 'clock')),
);
}
@@ -365,27 +419,43 @@ async function copyIncident() {
const isOpen = () => inc.status !== 'resolved';
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
function actionBar() {
let primary;
let secondary;
// primaryAction and secondaryAction are factories, not shared nodes — a
// button can only live in one place, and quickActions() below needs its own
// copy of the primary one rather than the actionbar's.
function primaryAction() {
if (inc.status === 'triggered') {
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
} else if (inc.status === 'acknowledged') {
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
} else {
primary = inc.archived_at
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
}
if (inc.status === 'acknowledged') {
return h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
}
return inc.archived_at
? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive')
: h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive');
}
function secondaryAction() {
if (isOpen()) {
secondary = isSnoozed()
return isSnoozed()
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
} else {
secondary = h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
}
const more = h('button', { class: 'btn btn-icon', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'));
const bar = h('div', { class: 'actionbar' }, primary, secondary, more);
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
}
// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen
// right away, next to the status it responds to. The sticky actionbar below
// keeps carrying every action, primary included, for whenever the page has
// been scrolled past it.
function quickActions() {
const div = h('div', { class: 'detail-quick-actions' }, primaryAction());
if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true;
return div;
}
function actionBar() {
const more = h('button', { class: 'btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), more);
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
return bar;
}
+72 -24
View File
@@ -6,8 +6,8 @@
// every team the viewer is in, because somebody on two rotas wants both.
import * as api from './api.js';
import { h, clear, icon, spinner } from './ui.js';
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js';
import { h, clear, badge, icon, spinner } from './ui.js';
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
import { myID, currentTeam } from './state.js';
const view = () => document.getElementById('view-oncall');
@@ -68,7 +68,7 @@ function render() {
}
function you(userID) {
return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null;
return userID === myID() ? badge('you', 'plain st-oncall you-badge') : null;
}
// One card per team with somebody on call, and a single empty card when there
@@ -99,20 +99,48 @@ function nowCard() {
)));
}
// Groups the week's 7 days into runs held by the same person (or the same
// empty slot) — the week's own version of the consecutive-day grouping
// myShifts does for a single person's own dates, below. Seven identical rows
// for one person all week collapses to the one bar this way.
function weekRuns(byDate) {
const runs = [];
for (let i = 0; i < 7; i++) {
const date = isoDate(addDays(weekStart, i));
const e = byDate.get(date) || null;
const uid = e ? e.user_id : null;
const last = runs[runs.length - 1];
if (last && last.uid === uid) last.to = date;
else runs.push({ uid, entry: e, from: date, to: date });
}
return runs;
}
function weekCard() {
const byDate = new Map(data.week.map((e) => [e.date, e]));
const today = isoDate(new Date());
const days = [];
for (let i = 0; i < 7; i++) {
const d = addDays(weekStart, i);
const key = isoDate(d);
const e = byDate.get(key);
days.push(h('li', { class: `day ${key === today ? 'today' : ''} ${key < today ? 'past' : ''}` },
h('span', { class: 'day-name', text: dayName.format(d) }),
h('span', { class: 'day-date', text: dayDate.format(d) }),
h('span', { class: `day-who ${e ? '' : 'nobody'}` }, e ? e.username : 'nobody', e && you(e.user_id)),
));
}
const mine = myID();
const days = weekRuns(byDate).map((r) => {
const single = r.from === r.to;
const cls = [
'day',
single ? '' : 'range',
r.from <= today && today <= r.to ? 'today' : '',
r.to < today ? 'past' : '',
r.uid === mine ? 'mine' : '',
].filter(Boolean).join(' ');
const label = single
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
: [h('span', {
class: 'day-range',
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
})];
return h('li', { class: cls },
...label,
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
);
});
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
return [
h('div', { class: 'page-head' },
@@ -121,12 +149,12 @@ function weekCard() {
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
icon('chevronLeft')),
h('button', {
class: 'btn btn-ghost label',
class: 'btn btn-ghost week-label',
type: 'button',
title: 'Back to this week',
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
text: `Week ${isoWeek(weekStart)}`,
}),
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
icon('chevronRight')),
),
@@ -135,7 +163,9 @@ function weekCard() {
];
}
// myShifts groups your upcoming dates into runs of consecutive days.
// myShifts groups your upcoming dates into runs of consecutive days, then
// splits off the one you're already in — listing it again under "Next
// shifts" told people they hadn't started a shift they were already on.
function myShifts() {
const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort();
const runs = [];
@@ -145,16 +175,34 @@ function myShifts() {
else runs.push({ from: date, to: date });
}
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
return [
h('div', { class: 'page-head' }, h('h2', { text: 'Your next shifts' })),
const label = (r) => (r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}`);
const today = isoDate(new Date());
const current = runs[0] && runs[0].from <= today ? runs[0] : null;
const next = current ? runs.slice(1) : runs;
const currentCard = current ? [
h('div', { class: 'page-head' }, h('h2', { text: 'Current shift' })),
h('div', { class: 'card' },
runs.length
? h('ul', { class: 'shift-list' }, runs.slice(0, 8).map((r) =>
h('ul', { class: 'shift-list' }, h('li', {},
h('span', { text: label(current) }),
h('span', { class: 'muted', text: `ends in ${duration(addDays(parse(current.to), 1) - Date.now())}` })))),
] : [];
return [
...currentCard,
h('div', { class: 'page-head' }, h('h2', { text: current ? 'Next shifts' : 'Your next shifts' })),
h('div', { class: 'card' },
next.length
? h('ul', { class: 'shift-list' }, next.slice(0, 8).map((r) =>
h('li', {},
h('span', { text: r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}` }),
h('span', { text: label(r) }),
h('span', { class: 'muted', text: days(r) })),
))
: h('div', { class: 'empty', text: 'Nothing scheduled in the next 60 days.' })),
: h('div', {
class: 'empty',
text: current ? 'Nothing else scheduled in the next 60 days.' : 'Nothing scheduled in the next 60 days.',
})),
];
}
+41 -14
View File
@@ -2,7 +2,7 @@
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
@@ -18,12 +18,12 @@ const FILTERS = [
];
const EMPTY = {
open: ['All clear', 'Nothing open right now.'],
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.'],
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.'],
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.'],
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.'],
archived: ['Nothing archived', ''],
open: ['All clear', 'Nothing open right now.', 'checkCircle'],
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.', 'checkCircle'],
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.', 'checkCircle'],
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.', 'clock'],
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.', null],
archived: ['Nothing archived', 'Resolved incidents land here once archived.', 'archive'],
};
onboarding.onRerender(() => renderList());
@@ -87,6 +87,10 @@ export async function refresh({ fresh = false } = {}) {
if (requested !== filter) return;
error = err.message;
}
// state.open (what the chip counts read) has just been refreshed too, by
// whichever caller updated it before calling here — app.js's poll, or the
// `cached` branch above.
renderChips();
renderList();
}
@@ -101,18 +105,32 @@ function setFilter(id) {
refresh({ fresh: true });
}
// Counts for the three chips derivable from the open list already fetched
// for the badges — Snoozed/Resolved/Archived would need a request of their
// own, so those chips stay count-less for now.
function chipCount(id) {
const open = state.selectedTeamID == null
? state.open
: state.open.filter((i) => i.team_id === state.selectedTeamID);
if (id === 'open') return open.length;
if (id === 'triggered') return open.filter((i) => i.status === 'triggered').length;
if (id === 'acknowledged') return open.filter((i) => i.status === 'acknowledged').length;
return null;
}
function renderChips() {
const el = document.getElementById('queue-filters');
const chips = FILTERS.map((f) =>
h('button', {
const chips = FILTERS.map((f) => {
const count = chipCount(f.id);
return h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
text: f.label,
}),
);
}, count != null && h('span', { class: 'count', text: String(count) }));
});
// Somebody in one team has nothing to choose between, so the row of team
// chips appears only when there is more than one. The default is all of
@@ -140,6 +158,12 @@ function renderChips() {
}
}
// 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);
}
@@ -155,8 +179,8 @@ function renderList() {
return;
}
if (!items.length) {
const [title, text] = EMPTY[filter];
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
const [title, text, iconName] = EMPTY[filter];
clear(el, checklist, emptyState(title, text, iconName));
return;
}
clear(el,
@@ -201,9 +225,12 @@ function row(inc, index) {
dataset: { index: String(index) },
},
h('div', { class: 'row-title', text: inc.title }),
h('div', { class: 'row-age', title: inc.triggered_at, text: age(inc.triggered_at) }),
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
h('div', { class: 'row-meta' },
status,
// The left-border colour alone doesn't say what it means; spell it out
// too, same badge the incident detail page uses for severity.
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
assignee,
team,
labels && h('span', { class: 'labels', text: labels }),
+1 -1
View File
@@ -80,7 +80,7 @@ function render() {
if (error && !data) body = h('div', { class: 'load-error', text: error });
else if (!data) body = spinner();
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
body = emptyState('No data in this range', '', 'chart');
body = emptyState('No data in this range', 'Nothing happened in this window.', 'chart');
} else {
body = h('div', { class: 'stats' },
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
+5 -5
View File
@@ -182,7 +182,7 @@ function subnav() {
// to the global team selector in the nav, which is what onTeamChange above
// reacts to.
function teamPicker() {
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
return h('h1', { class: 'detail-title', text: data.team.name });
}
// --- overview --------------------------------------------------------------
@@ -204,18 +204,18 @@ function overview() {
menuCard('/team/rota', 'Rota', null,
onToday ? `${onToday.username} is on call today.` : 'Nobody is on call today.'),
menuCard('/team/members', 'Members', (data.members || []).length,
owners === 1 ? 'One owner.' : `${owners} owners.`),
owners === 1 ? '1 owner, full access.' : `${owners} owners, full access.`),
menuCard('/team/escalation', 'Escalation', levels || null,
levels
? `${levels === 1 ? 'One level' : `${levels} levels`}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
? `${levels} escalation level${levels === 1 ? '' : 's'}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
: 'No ladder — nobody but the first person is woken.'),
menuCard('/team/sources', 'Alert sources', keys || null,
keys
? (unused ? `${unused} of them never used.` : 'All in use.')
? (unused ? `${unused} of ${keys} never used.` : `${keys} key${keys === 1 ? '' : 's'}, all used recently.`)
: 'No key yet, so nothing can reach this team.'),
menuCard('/team/deadman', 'Dead man’s switches', switches || null,
switches
? (dead ? `${dead} of them silent.` : 'All quiet, as they should be.')
? (dead ? `${dead} of ${switches} silent.` : [icon('checkCircle', 'icon overview-note-icon'), ' Dead man’s switch: healthy.'])
: 'Nothing watched.'),
);
}
+4 -1
View File
@@ -5,7 +5,7 @@
// between, the same rule every other team-aware control in this app follows;
// see state.js's currentTeam() for why nobody with just one ever has to.
import { h, openSheet, closeSheet } from './ui.js';
import { h, icon, openSheet, closeSheet } from './ui.js';
import { state, currentTeam, setSelectedTeam, onTeamChange } from './state.js';
import { teamColorClass } from './format.js';
@@ -36,6 +36,9 @@ export function render() {
btn.replaceChildren(
h('span', { class: dotClass }),
h('span', { class: 'team-selector-label', text: label }),
// Without this the pill reads as a tag rather than something you can
// open; the chevron is the only thing that says "dropdown" here.
icon('chevronDown', 'icon team-selector-chevron'),
);
}
}
+6 -1
View File
@@ -52,6 +52,7 @@ const ICONS = {
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
chevronLeft: ['M15 18l-6-6 6-6'],
chevronRight: ['M9 6l6 6-6 6'],
chevronDown: ['M6 9l6 6 6-6'],
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
};
@@ -186,12 +187,16 @@ export function labelChip(k, v) {
// One entry in a section's overview: a card that is a link, carrying the count
// only that section can state. Both the Admin tab and the Team tab open on one
// of these menus, and a menu item is a shape rather than a page's own idea.
// note is usually just a string, but can be an array of children instead —
// deadman's "healthy" note below pairs text with a status icon.
export function menuCard(href, label, count, note) {
return h('a', { class: 'card overview-item', href },
h('div', { class: 'overview-head' },
h('strong', { text: label }),
count != null && h('span', { class: 'overview-count', text: String(count) })),
h('p', { class: 'muted small', text: note }),
typeof note === 'string'
? h('p', { class: 'muted small', text: note })
: h('p', { class: 'muted small' }, note),
);
}