From 7412456c5ad46df8500c7f6532356cd7dd4824c5 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Thu, 8 Oct 2026 14:07:24 +0200 Subject: [PATCH] Pad the settings cards, and make status and timeline readable at a glance The Team settings cards (Escalation, Sources, Members, Switches, Single sign-on) printed their text flush against the border with the button in the corner: .card has never had padding and these never added any. A card with a header row now pads itself, with the title left, the button right and a divider before the content. The incident page no longer carries the primary action twice. The copy up by the status is gone and the sticky bar keeps it; Note is the timeline's link, with Copy in the bar for a resolved incident, and the phone's More sheet drops what the bar already shows. In the queue, a row's title wraps to two lines so the namespace that tells rows apart is no longer cut off, zero counts on the filter chips are dimmed, and a row omits the status the filter already states and the team once the queue is narrowed to one. Status colours failed 4.5:1 against their own fill in the light theme (warning 3.6, info 4.1, critical 4.4, ok 4.45, snooze 4.49), so the light tokens are darker; the dark theme already passed and is unchanged. Severity badges now carry a shape as well as a colour, and each timeline event has an icon. An escalation that ran out of levels, a failed notification and a silent heartbeat stand out in amber or red. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui. --- internal/web/static/app.css | 64 ++++++++++++++++++++---------- internal/web/static/js/alerts.js | 6 +-- internal/web/static/js/incident.js | 55 ++++++++++++++----------- internal/web/static/js/queue.js | 16 +++++--- internal/web/static/js/ui.js | 17 ++++++++ 5 files changed, 104 insertions(+), 54 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index f56a418..d6d4735 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -20,23 +20,23 @@ --accent-text: #ffffff; --accent-soft: #e8eefc; - --crit: #d0342c; + --crit: #c22d26; --crit-soft: #fdecea; - --warn: #b86e00; + --warn: #8f5500; --warn-soft: #fdf3e1; - --info: #2f6fdf; + --info: #245fc7; --info-soft: #e9f0fd; - --ok: #1d7f4c; + --ok: #1a7445; --ok-soft: #e6f5ec; - --snooze: #6b5bd2; + --snooze: #6050c8; --snooze-soft: #efedfb; /* Two hues that mean nothing on their own. The rota needs six colours to tell six people apart and the palette above only has four that are not already an alarm. */ - --teal: #0f7d8c; + --teal: #0c7180; --teal-soft: #e3f4f6; - --pink: #b3427e; + --pink: #a63b73; --pink-soft: #fbe8f2; --radius: 10px; @@ -387,6 +387,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh .chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); } .theme-picker { padding: 0 0 8px; } .chip .count { margin-left: 4px; opacity: 0.7; } +/* Nothing in it: step back so the chips that have something stand out. */ +.chip .count.zero { opacity: 0.4; } /* An overlay, not a flex item: absolute against .chips' own (non-scrolling) box stays flush with its real right edge regardless of scroll position, which turned out not to be true of position:sticky here — as a flex @@ -422,7 +424,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh background: var(--sev, var(--border-strong)); } .row-title { - font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-weight: 650; overflow: hidden; overflow-wrap: anywhere; + /* Two lines, not one: titles differ at the end ("PodRestarting + (namespace=...") and an ellipsis cut exactly the part that tells rows apart. */ + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; } .row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; } .row-meta { @@ -481,6 +486,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh } .badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } .badge.plain::before { display: none; } +.badge .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; } .badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); } .badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); } .badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); } @@ -516,10 +522,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh .detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; } .detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; } .detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } -/* A copy of the sticky actionbar's primary button, right under the status - it responds to — see quickActions() in incident.js. */ -.detail-quick-actions { margin-bottom: 14px; } -.detail-quick-actions .btn-primary { font-size: 16px; min-height: 44px; } .card { background: var(--surface); @@ -588,18 +590,26 @@ details[open] > summary { margin-bottom: 8px; } .tl-item::before { content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border); } -.tl-item:first-child::before { top: 16px; } -.tl-item:last-child::before { bottom: calc(100% - 16px); } +.tl-item:first-child::before { top: 18px; } +.tl-item:last-child::before { bottom: calc(100% - 18px); } .tl-dot { position: relative; z-index: 1; - width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%; - background: var(--surface); border: 2px solid var(--faint); + display: grid; place-items: center; + width: 20px; height: 20px; border-radius: 50%; + background: var(--surface); border: 2px solid var(--border-strong); color: var(--muted); } -.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); } -.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); } -.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); } -.tl-snoozed .tl-dot { border-color: var(--snooze); } -.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); } +/* An event with no icon keeps the old plain dot. */ +.tl-dot:empty { width: 10px; height: 10px; margin: 5px 0 0 5px; } +.tl-icon { width: 11px; height: 11px; stroke-width: 2.6; } +.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); color: var(--crit); } +.tl-acknowledged .tl-dot { border-color: var(--warn); color: var(--warn); } +.tl-resolved .tl-dot { border-color: var(--ok); color: var(--ok); } +.tl-snoozed .tl-dot { border-color: var(--snooze); color: var(--snooze); } +.tl-note .tl-dot, .tl-resolution_note .tl-dot { border-color: var(--accent); color: var(--accent); } +.tl-problem .tl-dot { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); } +.tl-problem.tl-notify_failed .tl-dot, .tl-problem.tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit-soft); color: var(--crit); } +.tl-problem .tl-text { color: var(--warn); font-weight: 600; } +.tl-problem.tl-notify_failed .tl-text, .tl-problem.tl-deadman_silent .tl-text { color: var(--crit); } .tl-body { min-width: 0; font-size: 14px; } .tl-text { overflow-wrap: anywhere; } .tl-text .who { font-weight: 650; } @@ -906,7 +916,17 @@ kbd { Six columns do not fit a phone, so the table scrolls inside its card rather than the page. A heartbeat under a switch with several is indented, the way the escalation ladder indents its levels. */ -.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +/* A card with a header row (the Team and Admin settings cards) pads itself: + plain .card has no padding, so these used to print their text flush against + the border with the button jammed in the corner. The divider separates the + title row from the content below it. */ +.card:has(> .card-head) { padding: 20px; } +@media (min-width: 900px) { .card:has(> .card-head) { padding: 24px; } } +.card-head { + display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; + padding-bottom: 14px; margin-bottom: 14px; border-bottom: 1px solid var(--border); +} +.card-head h2 { margin: 0; font-size: 17px; font-weight: 650; } .table-scroll { overflow-x: auto; margin-top: 12px; } .status-table th, .status-table td { white-space: nowrap; } .status-table td.wrap { white-space: normal; min-width: 12em; } diff --git a/internal/web/static/js/alerts.js b/internal/web/static/js/alerts.js index 19ebb7f..28fd585 100644 --- a/internal/web/static/js/alerts.js +++ b/internal/web/static/js/alerts.js @@ -2,8 +2,8 @@ // incident it belongs to, which is where anything can be done about it. import * as api from './api.js'; -import { h, clear, badge, emptyState, spinner } from './ui.js'; -import { age, severityClass, labelSummary } from './format.js'; +import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js'; +import { age, labelSummary } from './format.js'; const FILTERS = [ { id: 'firing', label: 'Firing', query: { status: 'firing' } }, @@ -87,7 +87,7 @@ function row(a) { 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' }, badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`), - sev && badge(sev, `plain ${severityClass(sev)}`), + sev && severityBadge(sev), summary && h('span', { text: summary }), labels && h('span', { class: 'labels', text: labels }), ), diff --git a/internal/web/static/js/incident.js b/internal/web/static/js/incident.js index 4b69eda..0b63648 100644 --- a/internal/web/static/js/incident.js +++ b/internal/web/static/js/incident.js @@ -4,10 +4,10 @@ import * as api from './api.js'; import * as poll from './poll.js'; import { - h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState, + h, clear, icon, badge, severityBadge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState, } from './ui.js'; import { - ago, when, until, duration, isFuture, severityClass, STATUS_LABEL, + ago, when, until, duration, isFuture, STATUS_LABEL, } from './format.js'; import { myID, users } from './state.js'; import { back } from './app.js'; @@ -86,7 +86,6 @@ function render() { error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), h('h1', { class: 'detail-title', text: inc.title }), h('div', { class: 'detail-badges' }, statusBadges()), - quickActions(), facts(), groupLabels(), alertsSection(), @@ -100,7 +99,7 @@ function render() { function statusBadges() { const out = []; - if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`)); + if (inc.severity) out.push(severityBadge(inc.severity)); out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`)); if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) { out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed')); @@ -145,7 +144,7 @@ function facts() { : ackActorLabel() || 'Unassigned'; rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' }, h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))), - inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`), + inc.severity && severityBadge(inc.severity), h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible), )); @@ -339,10 +338,30 @@ function timelineSection() { const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note'; +// One icon per kind of event, so the rail says what happened before the text +// does. Anything not listed keeps a plain dot. +const EVENT_ICON = { + triggered: 'bell', notified: 'bell', escalated: 'arrowUp', + acknowledged: 'check', unacknowledged: 'undo', assigned: 'user', + snoozed: 'clock', unsnoozed: 'clock', resolved: 'checkCircle', + alert_added: 'plus', alert_resolved: 'check', + archived: 'archive', unarchived: 'undo', + note: 'note', resolution_note: 'note', + notify_failed: 'alertTriangle', deadman_silent: 'alertTriangle', +}; + +// An escalation that ran out of levels, or of anywhere to send the page, is +// the one event on the rail that means "nobody is being told": it gets the +// warning treatment instead of reading like any other step. +const isProblem = (ev) => ev.type === 'notify_failed' || ev.type === 'deadman_silent' + || (ev.type === 'escalated' && /exhausted/i.test(ev.detail || '')); + function timelineItem(ev) { const mine = isNote(ev) && ev.user_id === myID(); - return h('li', { class: `tl-item tl-${ev.type}` }, - h('span', { class: 'tl-dot' }), + const problem = isProblem(ev); + const iconName = problem ? 'alertTriangle' : EVENT_ICON[ev.type]; + return h('li', { class: `tl-item tl-${ev.type}${problem ? ' tl-problem' : ''}` }, + h('span', { class: 'tl-dot' }, iconName && icon(iconName, 'icon tl-icon')), h('div', { class: 'tl-body' }, h('div', { class: 'tl-text' }, eventText(ev)), h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }), @@ -452,9 +471,8 @@ async function copyIncident() { const isOpen = () => inc.status !== 'resolved'; const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until); -// primaryAction and secondaryAction are factories, not shared nodes — a -// button can only live in one place, and quickActions() below needs its own -// copy of the primary one rather than the actionbar's. +// primaryAction and secondaryAction are factories, not shared nodes: a button +// can only live in one place, and the actionbar is rebuilt on every render. function primaryAction() { if (inc.status === 'triggered') { return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge'); @@ -473,17 +491,7 @@ function secondaryAction() { ? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze') : h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze'); } - return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note'); -} - -// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen -// right away, next to the status it responds to. The sticky actionbar below -// keeps carrying every action, primary included, for whenever the page has -// been scrolled past it. -function quickActions() { - const div = h('div', { class: 'detail-quick-actions' }, primaryAction()); - if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true; - return div; + return h('button', { class: 'btn', type: 'button', onclick: copyIncident }, icon('copy'), 'Copy'); } // Desktop has room to show what a phone folds into the More sheet below — see @@ -494,7 +502,6 @@ function extraActions() { if (!isOpen()) return []; const out = [ h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'), - h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: addNote }, icon('note'), 'Add note…'), ]; out.push(inc.status === 'acknowledged' ? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack') @@ -690,9 +697,9 @@ async function moreMenu() { items.push(h('li', { class: 'menu-sep', role: 'separator' })); items.push(item('checkCircle', 'Resolve…', resolve, 'danger')); } else { + // The bar already carries the primary action (Archive/Unarchive) and + // Copy, so the sheet adds only what it lacks. items.push(item('note', 'Add note…', addNote)); - items.push(item('copy', 'Copy incident', copyIncident)); - items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive)); } const fn = await openSheet(() => [ diff --git a/internal/web/static/js/queue.js b/internal/web/static/js/queue.js index 3d9dab3..65ee524 100644 --- a/internal/web/static/js/queue.js +++ b/internal/web/static/js/queue.js @@ -1,7 +1,7 @@ // The incident queue: filter chips and a list of incident rows. import * as api from './api.js'; -import { h, clear, badge, emptyState, spinner } from './ui.js'; +import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.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'; @@ -129,7 +129,7 @@ function renderChips() { 'aria-selected': String(f.id === filter), onclick: () => setFilter(f.id), text: f.label, - }, count != null && h('span', { class: 'count', text: String(count) })); + }, count != null && h('span', { class: count === 0 ? 'count zero' : 'count', text: String(count) })); }); // Somebody in one team has nothing to choose between, so the row of team @@ -194,6 +194,10 @@ function row(inc, index) { const snoozed = isFuture(inc.snoozed_until); const resolved = inc.status === 'resolved'; + // A filter that already says what the status is (Triggered, Resolved, + // Archived) would only have every row repeat it. + const impliedStatus = filter === 'triggered' || filter === 'resolved' || filter === 'archived'; + let status; if (resolved) status = badge('Resolved', 'st-resolved'); else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed'); @@ -214,7 +218,9 @@ function row(inc, index) { // The team is shown only to somebody who is in more than one. For everybody // else it is the same word on every row, which is noise rather than // information. - const team = state.teams.length > 1 && inc.team_name + // ...and not when the queue is already narrowed to one team: that team is + // the same word on every row there too. + const team = state.teams.length > 1 && state.selectedTeamID == null && inc.team_name ? h('span', { class: 'row-team', text: inc.team_name }) : null; @@ -227,10 +233,10 @@ function row(inc, index) { h('div', { class: 'row-title', text: inc.title }), h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }), h('div', { class: 'row-meta' }, - status, + !impliedStatus && status, // The left-border colour alone doesn't say what it means; spell it out // too, same badge the incident detail page uses for severity. - inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`), + inc.severity && severityBadge(inc.severity), assignee, team, labels && h('span', { class: 'labels', text: labels }), diff --git a/internal/web/static/js/ui.js b/internal/web/static/js/ui.js index c4dcd38..a973d06 100644 --- a/internal/web/static/js/ui.js +++ b/internal/web/static/js/ui.js @@ -1,5 +1,7 @@ // DOM helpers, the bottom sheet, confirmation and toasts. +import { severityClass } from './format.js'; + // h builds an element. attrs: class, text, on, dataset, aria/other // attributes; boolean true sets an empty attribute, false/null skips it. export function h(tag, attrs = {}, ...children) { @@ -32,6 +34,11 @@ export function clear(el, ...children) { // Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything. const ICONS = { + alertTriangle: ['M12 4l9.5 16.5h-19z', 'M12 10v4.5', 'M12 17.5h.01'], + alertCircle: ['circle:12,12,9', 'M12 7.5v5', 'M12 16h.01'], + infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'], + arrowUp: ['M12 19V5', 'M6 11l6-6 6 6'], + plus: ['M12 5v14M5 12h14'], back: ['M15 18l-6-6 6-6'], more: ['M5 12h.01M12 12h.01M19 12h.01'], queueList: ['M4 6h16M4 12h16M4 18h10'], @@ -171,6 +178,16 @@ export function badge(text, cls = '') { return h('span', { class: `badge ${cls}`, text }); } +// The severity as a badge with a shape as well as a colour: a triangle for +// critical, a circle with ! for warning, a circle with i for anything else, so +// the coloured bar and badge are not the only thing that says it. +const SEVERITY_ICON = { 'sev-critical': 'alertTriangle', 'sev-warning': 'alertCircle' }; +export function severityBadge(severity) { + const cls = severityClass(severity); + return h('span', { class: `badge plain ${cls}` }, + icon(SEVERITY_ICON[cls] || 'infoCircle', 'icon badge-icon'), severity); +} + // Access granted by the identity provider's groups. The server refuses to edit // it by hand (it would be undone at the next sign-in), so the controls that // would try are disabled with this as their reason.