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>
196 lines
10 KiB
JavaScript
196 lines
10 KiB
JavaScript
import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js";
|
|
import { get, change, describeResult, draftQuery } from "../api.js";
|
|
import { rerender } from "../app.js";
|
|
import { emailOf } from "./zones.js";
|
|
import { amlContext, suggestions } from "../aml.js";
|
|
|
|
const catalogCache = {};
|
|
async function catalog(scope) {
|
|
if (!catalogCache[scope]) catalogCache[scope] = await get("/catalog/options", { scope });
|
|
return catalogCache[scope];
|
|
}
|
|
|
|
// valueWidget returns {el, get()} for editing a value of the given kind.
|
|
// ctx holds the ACL and key names to suggest for address match lists.
|
|
function valueWidget(stmt, value, ctx) {
|
|
const kind = stmt ? stmt.kind : "raw";
|
|
if (kind === "bool") {
|
|
const s = select(["yes", "no"], value || "yes");
|
|
return { el: s, get: () => s.value };
|
|
}
|
|
if (kind === "enum") {
|
|
const s = select(stmt.choices, value || stmt.choices[0]);
|
|
return { el: s, get: () => s.value };
|
|
}
|
|
if (kind === "address_match_list") {
|
|
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
|
|
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
|
|
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
|
|
}
|
|
if (kind === "raw") {
|
|
const t = codeArea(value || "");
|
|
// Statements such as allow-transfer and listen-on take an address
|
|
// match list after optional parameters: suggest names at the cursor.
|
|
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
|
|
if (takesList && !value) t.placeholder = "homelab; key xfer-key";
|
|
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
|
|
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
|
|
}
|
|
const i = input({ value: value || "", class: "mono" });
|
|
return { el: i, get: () => i.value.trim() };
|
|
}
|
|
|
|
// codeArea is a textarea for named.conf text: as tall as its content
|
|
// (up to 30 lines), Tab indents, Enter keeps the line's indentation. After
|
|
// Esc, Tab moves on to the next field again.
|
|
function codeArea(value) {
|
|
const t = textarea(value, { class: "mono code", spellcheck: "false" });
|
|
const fit = () => { t.rows = Math.min(30, Math.max(3, t.value.split("\n").length + 1)); };
|
|
fit();
|
|
let tabMoves = false;
|
|
const insert = (text) => {
|
|
const { selectionStart: a, selectionEnd: b } = t;
|
|
t.setRangeText(text, a, b, "end");
|
|
fit();
|
|
};
|
|
t.addEventListener("input", fit);
|
|
t.addEventListener("focus", () => { tabMoves = false; });
|
|
t.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Escape") { tabMoves = true; return; }
|
|
if (ev.key === "Tab" && !tabMoves && !ev.shiftKey) {
|
|
ev.preventDefault();
|
|
insert("\t");
|
|
} else if (ev.key === "Enter" && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
|
|
const line = t.value.slice(0, t.selectionStart).split("\n").pop();
|
|
let indent = line.match(/^\t*/)[0];
|
|
if (line.trimEnd().endsWith("{")) indent += "\t";
|
|
ev.preventDefault();
|
|
insert("\n" + indent);
|
|
}
|
|
});
|
|
return t;
|
|
}
|
|
|
|
// optionsEditor renders the options of one scope.
|
|
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
|
|
// With a draft open, show the options as they will be: a staged edit
|
|
// stays visible after Save.
|
|
const [opts, cat, ctx] = await Promise.all([get("/options", draftQuery({ scope, scope_id })), catalog(catalogScope), amlContext()]);
|
|
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
|
|
const err = h("div");
|
|
const listEl = h("div");
|
|
let editing = null;
|
|
const draw = () => {
|
|
mount(listEl, table([
|
|
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
|
|
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code", style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.formatted || o.value || "(no value)") },
|
|
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
|
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
|
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
|
h("button", { class: "small danger", onclick: () => del(o) }, "Delete")), tdClass: "actions-cell" },
|
|
], opts, { empty: "No options set; BIND's defaults apply." }));
|
|
};
|
|
const editRow = (o) => {
|
|
const w = valueWidget(byName[o.name], o.formatted || o.value, ctx);
|
|
const e2 = h("div");
|
|
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
|
|
h("div", { class: "actions", style: { marginTop: "6px" } },
|
|
h("button", { class: "primary small", onclick: async () => {
|
|
try {
|
|
const r = await change("PATCH", "/options/" + o.id, { value: w.get() });
|
|
toast(describeResult(r));
|
|
editing = null;
|
|
rerender();
|
|
} catch (ex) { mount(e2, errorBox(ex)); }
|
|
} }, "Save"),
|
|
h("button", { class: "small", onclick: () => { editing = null; draw(); } }, "Cancel")), e2);
|
|
};
|
|
const del = async (o) => {
|
|
if (!(await confirmDialog("Remove option", "Remove " + o.name + " (" + o.where + ")? BIND's default applies afterwards.", "Remove", "danger"))) return;
|
|
try {
|
|
const r = await change("DELETE", "/options/" + o.id);
|
|
toast(describeResult(r));
|
|
rerender();
|
|
} catch (ex) { toast(ex.message, "err", 8000); }
|
|
};
|
|
// Add form
|
|
const listId = "opt-" + Math.random().toString(36).slice(2);
|
|
const nameIn = input({ list: listId, placeholder: "Start typing an option name…", class: "mono", style: { width: "280px" } });
|
|
const dl = h("datalist", { id: listId }, cat.filter((s) => !s.managed).map((s) => h("option", { value: s.name }, s.description || "")));
|
|
const info = h("div", { class: "hint" });
|
|
const valueBox = h("div");
|
|
let widget = valueWidget(null, "", ctx);
|
|
const showInfo = () => {
|
|
const st = byName[nameIn.value.trim()];
|
|
widget = valueWidget(st, "", ctx);
|
|
mount(valueBox, widget.el, widget.hint ? h("div", { class: "hint" }, widget.hint) : null);
|
|
if (!st) { mount(info, nameIn.value ? "Not a known option here (see the BIND 9.20 reference)." : ""); return; }
|
|
mount(info, st.description || "", st.status ? [" ", badge(st.status, "warn")] : null, h("pre", { style: { marginTop: "4px", whiteSpace: "pre-wrap" } }, st.grammar));
|
|
};
|
|
nameIn.addEventListener("input", showInfo);
|
|
showInfo();
|
|
const add = async () => {
|
|
mount(err);
|
|
try {
|
|
const r = await change("POST", "/options", { scope, scope_id: scope_id || undefined, name: nameIn.value.trim(), value: widget.get() });
|
|
toast(describeResult(r));
|
|
rerender();
|
|
} catch (ex) { mount(err, errorBox(ex)); }
|
|
};
|
|
draw();
|
|
mount(el, h("div", { class: "panel" },
|
|
h("h2", null, title),
|
|
intro ? h("p", { class: "muted small" }, intro) : null,
|
|
listEl,
|
|
h("h3", null, "Add an option"),
|
|
h("div", { class: "form-grid" },
|
|
h("label", null, "Name"), h("div", null, nameIn, dl, info),
|
|
h("label", null, "Value"), valueBox),
|
|
err,
|
|
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: add }, "Add option"))));
|
|
}
|
|
|
|
export async function render(el) {
|
|
const settings = await get("/settings");
|
|
const settingsEl = h("div");
|
|
const serverEl = h("div");
|
|
const topEl = h("div");
|
|
mount(el,
|
|
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"),
|
|
h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))),
|
|
settingsEl, serverEl, topEl);
|
|
settingsForm(settingsEl, settings);
|
|
await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." });
|
|
await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements",
|
|
intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." });
|
|
}
|
|
|
|
function settingsForm(el, s) {
|
|
const ns = textarea((s.nameservers || []).join("\n"), { class: "mono", rows: 2 });
|
|
const rname = input({ value: s.rname ? emailOf(s.rname) : "", placeholder: "hostmaster@example.com" });
|
|
const ttl = input({ type: "number", value: s.default_ttl });
|
|
const refresh = input({ type: "number", value: s.refresh });
|
|
const retry = input({ type: "number", value: s.retry });
|
|
const expire = input({ type: "number", value: s.expire });
|
|
const neg = input({ type: "number", value: s.negative_ttl });
|
|
const policy = select(["date", "increment", "unixtime"], s.serial_policy);
|
|
const err = h("div");
|
|
mount(el, h("div", { class: "panel" },
|
|
h("h2", null, "Defaults for new zones"),
|
|
h("div", { class: "form-grid" },
|
|
field("Name servers", ns, "One per line; used as NS records for new primary zones."),
|
|
field("SOA contact", rname, "Email address (or mailbox name) for new zones."),
|
|
field("Default TTL", ttl),
|
|
field("Refresh", refresh), field("Retry", retry), field("Expire", expire),
|
|
field("Negative TTL", neg, "SOA minimum."),
|
|
field("Serial policy", policy, "date: YYYYMMDDnn, increment: +1, unixtime: seconds since 1970.")),
|
|
err,
|
|
h("div", { class: "actions", style: { marginTop: "10px" } }, h("button", { class: "primary", onclick: async () => {
|
|
mount(err);
|
|
try {
|
|
const r = await change("PATCH", "/settings", { nameservers: lines(ns.value), rname: rname.value.trim(), default_ttl: +ttl.value, refresh: +refresh.value, retry: +retry.value, expire: +expire.value, negative_ttl: +neg.value, serial_policy: policy.value });
|
|
toast(describeResult(r));
|
|
} catch (e) { mount(err, errorBox(e)); }
|
|
} }, "Save defaults"))));
|
|
}
|