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.
This commit is contained in:
Niklas Ye
2026-10-08 14:07:24 +02:00
parent 065b557860
commit 7412456c5a
5 changed files with 104 additions and 54 deletions
+31 -24
View File
@@ -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(() => [