423ed9b3a3
Statistics used to live only in terdut-tui; the account page said so. The page shows the same figures as the TUI's Stats tab -- incident counts, MTTA and MTTR, top alerts, and alert frequency by hour (UTC) and by day of week -- and adds a range picker (Today, 7d, 30d, 90d, All) that the TUI does not have. The ranges are day-granular because the server reads from/to as whole UTC dates, so there is no 24h chip. No server change: the page uses the existing /api/stats/* endpoints, which already scope to the caller's teams. Charts are inline SVG and plain elements sized from script, because the CSP forbids inline styles, inline scripts and CDN libraries. Removes the "statistics are in terdut-tui" notes from the account page and the README.
199 lines
6.5 KiB
JavaScript
199 lines
6.5 KiB
JavaScript
// 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'],
|
||
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' }));
|
||
}
|