// Small DOM helpers and widgets used by every page. // h creates an element. attrs may contain class, style, on // handlers, dataset, value/checked/disabled and any other attribute. export function h(tag, attrs, ...children) { const el = document.createElement(tag); if (attrs) { for (const [k, v] of Object.entries(attrs)) { if (v === undefined || v === null || v === false) continue; if (k === "class") el.className = v; else if (k === "style" && typeof v === "object") Object.assign(el.style, v); else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v); else if (k === "dataset") Object.assign(el.dataset, v); else if (k === "value") el.value = v; else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") 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 === undefined || c === false) continue; el.append(c instanceof Node ? c : document.createTextNode(String(c))); } } export function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); return el; } export function mount(el, ...children) { clear(el); append(el, children); return el; } // ---- formatting export function fmtTime(iso) { if (!iso) return ""; const d = new Date(iso); if (isNaN(d)) return iso; return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } export function relTime(iso) { if (!iso) return ""; const s = (Date.now() - new Date(iso).getTime()) / 1000; if (s < 60) return "just now"; if (s < 3600) return Math.floor(s / 60) + " min ago"; if (s < 86400) return Math.floor(s / 3600) + " h ago"; if (s < 86400 * 30) return Math.floor(s / 86400) + " days ago"; return fmtTime(iso); } export function time(iso) { return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso)); } export function display(name) { if (!name || name === ".") return "."; return name.endsWith(".") ? name.slice(0, -1) : name; } export function plural(n, word, words) { return n + " " + (n === 1 ? word : (words || word + "s")); } export function badge(text, kind) { return h("span", { class: "badge" + (kind ? " " + kind : "") }, text); } // ---- toasts export function toast(message, kind = "ok", ms = 3500) { const box = document.getElementById("toasts"); const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message); // A repeated message replaces the earlier one instead of stacking. for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove(); box.append(t); while (box.children.length > 3) box.firstChild.remove(); setTimeout(() => t.remove(), ms); } // ---- modal dialogs // modal shows a dialog. buttons: [{label, kind, value, onclick}]. // onclick may return false to keep the dialog open, or a promise. export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) { return new Promise((resolve) => { const back = h("div", { class: "modal-back" }); const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); }; const onKey = (e) => { if (e.key === "Escape") close(null); }; document.addEventListener("keydown", onKey); const btns = buttons.map((b) => h("button", { class: b.kind || "", type: "button", onclick: async (e) => { if (b.onclick) { e.target.disabled = true; try { const r = await b.onclick(); if (r === false) return; } finally { e.target.disabled = false; } } close(b.value === undefined ? b.label : b.value); }, }, b.label)); const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns)); back.append(dialog); back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); }); document.body.append(back); const first = dialog.querySelector("input, select, textarea"); if (first) first.focus(); }); } export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") { const r = await modal(title, h("p", null, message), [ { label: "Cancel", value: false }, { label: okLabel, kind, value: true }, ]); return r === true; } // ---- forms // field returns a label + control pair for .form-grid layouts. export function field(label, control, help) { const id = control.id || ("f" + Math.random().toString(36).slice(2, 9)); control.id = id; return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null]; } export function input(attrs = {}) { return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs }); } export function select(options, value, attrs = {}) { const s = h("select", attrs); for (const o of options) { const [v, label] = Array.isArray(o) ? o : [o, o]; s.append(h("option", { value: v, selected: String(v) === String(value) }, label)); } return s; } export function textarea(value, attrs = {}) { return h("textarea", { spellcheck: "false", ...attrs, value: value || "" }); } export function checkbox(label, checked, attrs = {}) { const c = h("input", { type: "checkbox", checked, ...attrs }); const l = h("label", { class: "check" }, c, label); l.input = c; return l; } // lines splits a textarea's value into non-empty trimmed lines. export function lines(text) { return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== ""); } // ---- tables // table renders rows with columns [{label, render(row) => node|string, class}]. export function table(columns, rows, opts = {}) { if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet."); const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label)))); const tbody = h("tbody"); rows.forEach((row, i) => { const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" }); for (const c of columns) { const v = c.render(row, i); tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v)); } tbody.append(tr); }); return h("div", { class: "table-wrap" }, h("table", null, thead, tbody)); } // ---- problems, diffs, errors export function problemsList(problems) { if (!problems || !problems.length) return null; return h("ul", { class: "problems" }, problems.map((p) => { const where = [p.view && "view " + p.view, p.zone && "zone " + display(p.zone)].filter(Boolean).join(", "); return h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, where ? h("span", { class: "where" }, where) : null); })); } // checkList shows named-checkconf's errors and warnings (not its routine // "loaded serial" lines). export function checkList(msgs) { const shown = (msgs || []).filter((m) => m.error || !(m.text.startsWith("loaded serial") || m.text === "OK")); if (!shown.length) return null; return h("ul", { class: "problems" }, shown.map((m) => h("li", { class: m.error ? "error" : "warning" }, h("strong", null, m.error ? "named-checkconf: " : "named-checkconf warning: "), m.text, m.object ? h("span", { class: "where" }, m.object) : null))); } export function diffView(text) { if (!text) return h("div", { class: "muted" }, "No file changes."); const box = h("div", { class: "diff" }); for (const line of text.replace(/\n$/, "").split("\n")) { let cls = ""; if (line.startsWith("+++") || line.startsWith("---")) cls = "file"; else if (line.startsWith("+")) cls = "add"; else if (line.startsWith("-")) cls = "del"; else if (line.startsWith("@@")) cls = "hunk"; box.append(h("div", { class: cls }, line || " ")); } return box; } // errorBox shows an API error with its problems and named-checkconf output. export function errorBox(err) { if (!err) return null; const d = err.details || {}; let problems = d.problems || []; // Don't repeat a single problem that is already the headline. if (problems.length === 1 && err.message.includes(problems[0].message)) problems = []; return h("div", { class: "msg err" }, h("div", null, err.message), problemsList(problems), checkList(d.check)); } export function copyBox(value) { const inp = input({ value, readOnly: true }); const btn = h("button", { type: "button", class: "small", onclick: async () => { try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); } } }, "Copy"); return h("div", { class: "copy-box" }, inp, btn); } // debounce delays fn until calls stop for ms. export function debounce(fn, ms = 250) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; }