Web UI: zone naming and reverse zones, ACL suggestions, record form

- New zone dialog: choose Domain or Reverse zone. Reverse zones are
  created from a network and their name is generated; both kinds show the
  exact zone name and flag likely mistakes (addresses or URLs as names,
  host-like names, duplicates in the view, parent/child zones, networks
  off the octet/nibble boundaries) before anything is submitted.
- Record form: clears every field but the type after an add; shows the
  record exactly as it will be stored and warns about unqualified names.
  In reverse zones it takes an address, defaults to PTR, suggests host
  names of A/AAAA records with that address and warns about a second PTR;
  the record list shows addresses.
- Address match lists (options, view match lists, ACL elements) offer the
  existing ACLs, TSIG keys and built-ins as one-click suggestions and
  flag unknown names.
- Server: in reverse zones a record can be named by its address, and a
  PTR target like nas.example.com without the final dot is read as the
  full host name (a bare label is refused). Warnings noticed while parsing
  record data reach the result again.
- Fixes: brand link hover underline, the dashboard's "New zone" link
  opening the dialog twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 20:49:43 +00:00
1 parent 3b7bee67f8
commit bf7f99874b
19 files changed
+577 -35

No files matched your search

+13 -7
View File
@@ -2,6 +2,7 @@ import { h, mount, table, field, input, select, textarea, toast, errorBox, badge
import { get, change, describeResult } 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) {
@@ -10,7 +11,8 @@ async function catalog(scope) {
}
// valueWidget returns {el, get()} for editing a value of the given kind.
function valueWidget(stmt, value) {
// 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");
@@ -23,11 +25,15 @@ function valueWidget(stmt, 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: t, 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." };
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 = textarea(value || "", { class: "mono", rows: 3 });
return { el: t, get: () => t.value.trim() };
// 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() };
}
const i = input({ value: value || "", class: "mono" });
return { el: i, get: () => i.value.trim() };
@@ -35,7 +41,7 @@ function valueWidget(stmt, value) {
// optionsEditor renders the options of one scope.
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
const [opts, cat] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope)]);
const [opts, cat, ctx] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope), amlContext()]);
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
const err = h("div");
const listEl = h("div");
@@ -51,7 +57,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
], opts, { empty: "No options set; BIND's defaults apply." }));
};
const editRow = (o) => {
const w = valueWidget(byName[o.name], o.value);
const w = valueWidget(byName[o.name], 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" } },
@@ -79,10 +85,10 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
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, "");
let widget = valueWidget(null, "", ctx);
const showInfo = () => {
const st = byName[nameIn.value.trim()];
widget = valueWidget(st, "");
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));