Files
terdut-server/internal/web/static/js/ui.js
T
Niklas Ye 7412456c5a 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.
2026-10-08 14:07:24 +02:00

231 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DOM helpers, the bottom sheet, confirmation and toasts.
import { severityClass } from './format.js';
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
// attributes; boolean true sets an empty attribute, false/null skips it.
export function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else if (k === 'dataset') Object.assign(el.dataset, v);
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
else if (k in el && typeof v !== 'string') el[k] = v;
else el.setAttribute(k, v === true ? '' : v);
}
append(el, children);
return el;
}
function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export function clear(el, ...children) {
el.replaceChildren();
append(el, children);
return el;
}
// 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'],
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
check: ['M5 12.5l4.5 4.5L19 7'],
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
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'],
};
const SVG = 'http://www.w3.org/2000/svg';
export function icon(name, cls = 'icon') {
const svg = document.createElementNS(SVG, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', cls);
for (const d of ICONS[name] || []) {
let node;
if (d.startsWith('circle:')) {
const [cx, cy, r] = d.slice(7).split(',');
node = document.createElementNS(SVG, 'circle');
node.setAttribute('cx', cx);
node.setAttribute('cy', cy);
node.setAttribute('r', r);
} else if (d.startsWith('rect:')) {
const [x, y, w, hgt, rx] = d.slice(5).split(',');
node = document.createElementNS(SVG, 'rect');
node.setAttribute('x', x);
node.setAttribute('y', y);
node.setAttribute('width', w);
node.setAttribute('height', hgt);
if (rx) node.setAttribute('rx', rx);
} else {
node = document.createElementNS(SVG, 'path');
node.setAttribute('d', d);
}
svg.append(node);
}
return svg;
}
// ---------- sheet ----------
const sheet = () => document.getElementById('sheet');
let sheetResolve = null;
// openSheet shows content in the bottom sheet (a centred dialog on desktop)
// and resolves with whatever closeSheet is given, or null when dismissed.
export function openSheet(build) {
const dlg = sheet();
if (dlg.open) closeSheet(null);
const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' }));
append(inner, [build()]);
clear(dlg, inner);
dlg.showModal();
const first = dlg.querySelector('[autofocus]');
if (first) first.focus();
return new Promise((resolve) => {
sheetResolve = resolve;
});
}
export function closeSheet(value = null) {
const dlg = sheet();
const resolve = sheetResolve;
sheetResolve = null;
if (dlg.open) dlg.close();
if (resolve) resolve(value);
}
export function sheetIsOpen() {
return sheet().open;
}
export function initSheet() {
const dlg = sheet();
// A tap on the backdrop lands on the dialog element itself.
dlg.addEventListener('click', (e) => {
if (e.target === dlg) closeSheet(null);
});
dlg.addEventListener('cancel', (e) => {
e.preventDefault();
closeSheet(null);
});
}
// confirm asks a yes/no question in the sheet.
export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) {
return openSheet(() => [
h('h2', { class: 'sheet-title', text: title }),
text && h('p', { class: 'sheet-text', text }),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }),
h('button', {
class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`,
type: 'button',
autofocus: true,
onclick: () => closeSheet(true),
text: confirmLabel,
}),
),
]).then((v) => v === true);
}
// ---------- toast ----------
let toastTimer = 0;
export function toast(message, kind = '') {
const el = document.getElementById('toast');
el.textContent = message;
el.className = `toast ${kind}`;
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
el.hidden = true;
}, kind === 'error' ? 5000 : 2500);
}
// ---------- misc ----------
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.
export const SSO_MANAGED = 'Managed by single sign-on. Change the person’s groups in the identity provider.';
export function ssoBadge(text = 'SSO') {
return h('span', { class: 'row-team sso', title: SSO_MANAGED, text });
}
export function labelChip(k, v) {
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: 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) })),
typeof note === 'string'
? h('p', { class: 'muted small', text: note })
: h('p', { class: 'muted small' }, note),
);
}
export function emptyState(title, text, iconName) {
return h('div', { class: 'empty' },
iconName && icon(iconName),
h('strong', { text: title }),
text && h('span', { text }),
);
}
export function spinner() {
return h('div', { class: 'empty' }, h('span', { class: 'spinner' }));
}