Views open a page like a zone's, with Settings, Options and Zones tabs (and New zone in that view); the Views page lists and orders them. A and AAAA records get a + CNAME button that fills in the add form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
11 KiB
JavaScript
297 lines
11 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 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) {
|
||
// 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, 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); };
|
||
}
|
||
|
||
// Where each list page was last left (with its filters), so a breadcrumb
|
||
// back to it restores them. The router and list pages fill it in.
|
||
export const lastList = {};
|
||
|
||
// 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;
|
||
}
|
||
|
||
// The colours a view can be shown in (besides the default, the link
|
||
// colour). Keep in step with model.ViewColors.
|
||
export const viewColors = ["green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"];
|
||
|
||
// viewLink links to a view's page, in the view's colour. v is a view
|
||
// from /views; name is shown when the view is unknown. back is the
|
||
// address the views page offers to go back to (the current page).
|
||
export function viewLink(v, name, back) {
|
||
if (!v) return name ? h("span", null, name) : h("span", { class: "muted" }, "–");
|
||
return h("a", { href: viewHref(v.id, back), class: v.color ? "vc-" + v.color : null, title: "View " + v.name }, v.name);
|
||
}
|
||
|
||
// viewHref is the address of a view's page.
|
||
export function viewHref(id, back) {
|
||
return "#/views/" + id + (back ? "?back=" + encodeURIComponent(back) : "");
|
||
}
|
||
|
||
// colorPicker picks a view colour; its value is "" for the default.
|
||
export function colorPicker(value) {
|
||
const el = h("div", { class: "swatches", role: "radiogroup" });
|
||
const draw = () => mount(el, ["", ...viewColors].map((c) => h("button", {
|
||
type: "button", role: "radio", "aria-checked": String(c === el.value),
|
||
class: "swatch" + (c ? " vc-" + c : " vc-default") + (c === el.value ? " selected" : ""),
|
||
title: c || "default (link colour)", "aria-label": c || "default",
|
||
onclick: () => { el.value = c; draw(); },
|
||
})));
|
||
el.value = value || "";
|
||
draw();
|
||
return el;
|
||
}
|