Files
housecarl/internal/web/static/assets/ui.js
T
abrown38andClaude Opus 5.5 2880f7ddc9 Add changesets, bandwidth limits and quotas; generic examples
Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:18:54 +01:00

252 lines
9.4 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)));
}
// diffView shows a unified diff, lines colored.
export function diffView(text) {
if (!text) return h("div", { class: "muted" }, "The ruleset doesn't change.");
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.
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;
}
// readOnly locks a form for someone who may look but not change: every
// control is disabled, and the editors' add and remove buttons hidden.
export function readOnly(el) {
for (const c of el.querySelectorAll("input, select, textarea")) c.disabled = true;
for (const b of el.querySelectorAll("button")) b.classList.add("hidden");
return el;
}