Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
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 on the views page.
|
||
export function viewHref(id, back) {
|
||
return "#/views?view=" + 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;
|
||
}
|