Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
8.5 KiB
JavaScript
229 lines
8.5 KiB
JavaScript
// Small DOM helpers and widgets used by every page.
|
|
|
|
// h creates an element. attrs may contain class, style, on<event>
|
|
// 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", hourCycle: "h23" });
|
|
}
|
|
|
|
export function relTime(iso) {
|
|
if (!iso) return "";
|
|
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
const s = Math.abs(diff);
|
|
if (s < 60) return "just now";
|
|
let n;
|
|
if (s < 3600) n = Math.floor(s / 60) + " min";
|
|
else if (s < 86400) n = Math.floor(s / 3600) + " h";
|
|
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
|
|
else return fmtTime(iso);
|
|
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
|
|
}
|
|
|
|
export function time(iso) {
|
|
return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso));
|
|
}
|
|
|
|
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) {
|
|
// The label points at the control itself, also when it is wrapped.
|
|
const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control;
|
|
const id = target.id || ("f" + Math.random().toString(36).slice(2, 9));
|
|
target.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 and errors
|
|
|
|
export function problemsList(problems) {
|
|
if (!problems || !problems.length) return null;
|
|
return h("ul", { class: "problems" }, problems.map((p) =>
|
|
h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null)));
|
|
}
|
|
|
|
// errorBox shows an API error with its problems.
|
|
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));
|
|
}
|
|
|
|
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); };
|
|
}
|
|
|
|
// backLink is the "← Zones" link above a page's heading, back to the
|
|
// page it was opened from.
|
|
export function backLink(label, href) {
|
|
return h("nav", { class: "back-link", "aria-label": "Back" }, h("a", { href }, "← ", label));
|
|
}
|
|
|
|
// safeBack returns a "back" address taken from the URL if it is one of
|
|
// the UI's own (#/...), else null: it ends up in an href.
|
|
export function safeBack(v) {
|
|
return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null;
|
|
}
|