Files
terdut-server/internal/web/static/js/ui.js
T
Niklas Ye 591d5b8df0 Copy an incident to the clipboard as Markdown
A button in the incident header (also `y`, and "Copy incident" in the
more menu) puts everything the page knows on the clipboard, for pasting
into a chat or an agent prompt with no integration involved.

The text carries the facts, every alert with all its labels and
annotations (the page only shows summary or description), the timeline
with notes in full, and the "Seen before" resolution notes. Times are
ISO 8601 and users are named rather than "you", since relative and
first-person wording is ambiguous once pasted elsewhere.

The async clipboard API needs a secure context and this server is often
reached over plain HTTP, so it falls back to execCommand.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-09-25 19:12:33 +02:00

200 lines
6.6 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.
// 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 = {
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'],
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 });
}
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.
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 }),
);
}
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' }));
}