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

+3 -1
View File
@@ -1,4 +1,5 @@
import { h, mount, table, field, input, textarea, toast, errorBox, confirmDialog, modal, lines } from "../ui.js";
import { amlContext, suggestions } from "../aml.js";
import { get, change, describeResult } from "../api.js";
import { rerender } from "../app.js";
@@ -23,12 +24,13 @@ export async function render(el) {
async function aclDialog(a) {
const err = h("div");
const ctx = await amlContext();
const name = input({ value: a ? a.name : "", placeholder: "homelab", class: "mono" });
const elems = textarea(a ? a.elements.join("\n") : "", { class: "mono", rows: 8, placeholder: "192.168.1.0/24\n10.0.0.0/8\n!10.0.0.66\nkey xfer-key\nlocalhost" });
const comment = input({ value: a ? a.comment : "" });
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
field("Name", name, a ? "Renaming updates every reference automatically." : ""),
field("Elements", elems, "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
+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));
+4 -2
View File
@@ -1,4 +1,5 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js";
import { amlContext, suggestions } from "../aml.js";
import { get, change, describeResult } from "../api.js";
import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
@@ -44,6 +45,7 @@ export async function render(el, params, query) {
async function viewDialog(v, viewless) {
const err = h("div");
const ctx = await amlContext();
const name = input({ value: v ? v.name : "", placeholder: "internal" });
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
@@ -52,8 +54,8 @@ async function viewDialog(v, viewless) {
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
const body = h("div", null, h("div", { class: "form-grid" },
field("Name", name),
field("Match clients", mc, "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
field("Match destinations", md, "Server addresses this view answers on (optional)."),
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
field("Recursive only", mro, "If yes, only recursive queries match this view."),
field("Comment", comment)),
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
+99 -11
View File
@@ -3,6 +3,7 @@ import { api, get, change, describeResult, rrtypes, state, draftQuery } from "..
import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js";
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
export async function render(el, params, query) {
const id = params.id;
@@ -53,26 +54,104 @@ async function recordsTab(el, zone, query) {
const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]);
const typeInfo = Object.fromEntries(types.map((t) => [t.type, t]));
const err = h("div");
// Add form
const fName = input({ placeholder: "@ or www", style: { width: "180px" }, class: "mono" });
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), "A", { style: { width: "110px" } });
const zoneName = zone.name.replace(/\.$/, "");
const reverse = isReverseZone(zone.name);
const rinfo = reverse ? reverseInfo(zone.name) : null;
// Add form. In reverse zones records are named by address.
const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false" });
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), reverse ? "PTR" : "A", { style: { width: "110px" } });
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
const hint = h("div", { class: "hint" });
const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
const suggest = h("div", { class: "aml-suggest" });
const showHint = () => {
const t = typeInfo[fType.value] || {};
fData.placeholder = t.example || t.format || "";
fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || "";
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA"));
};
fType.addEventListener("change", showHint);
fType.addEventListener("change", () => { showHint(); preview(); });
showHint();
// ownerOf shows what the Name field becomes.
const ownerOf = (v) => {
v = v.trim();
if (reverse) {
const r = ptrOwner(v || "@", zone.name);
if (!v || v === "@") return { fqdn: zoneName };
return r;
}
if (!v || v === "@") return { fqdn: zoneName };
if (v.endsWith(".")) return { fqdn: v.replace(/\.$/, "").toLowerCase() };
const low = v.toLowerCase();
if (low === zoneName || low.endsWith("." + zoneName)) return { fqdn: low, note: "\"" + v + "\" already ends with the zone name, so it is read as " + low + " (end it with a dot to be explicit)" };
return { fqdn: low + "." + zoneName };
};
// Which word of the data is a domain name, per type.
const targetIndex = { CNAME: 0, DNAME: 0, NS: 0, PTR: 0, MX: 1, SRV: 3, KX: 1, AFSDB: 1 };
const dataOf = (type, data) => {
const words = data.trim().split(/\s+/);
const i = targetIndex[type];
if (i === undefined || words.length <= i || !words[i]) return { text: data.trim() };
const w = words[i];
let target, note, error;
if (w === "@") target = zoneName + ".";
else if (w.endsWith(".")) target = w;
else if (reverse && type === "PTR") {
if (w.includes(".")) target = w + ".";
else error = "give the host's full name, e.g. " + w + ".adbraun.lol (" + w + " alone would mean " + w + "." + zoneName + ")";
} else {
target = w + "." + zoneName + ".";
if (w.includes(".")) note = "\"" + w + "\" has no final dot, so it means " + target + "; write " + w + ". if you meant the name outside this zone";
}
if (error) return { error };
words[i] = target;
return { text: words.join(" "), note };
};
let lookupTimer = null, lookupFor = "";
const preview = () => {
const n = fName.value, d = fData.value;
if (!n.trim() && !d.trim()) { mount(willAdd); mount(suggest); return; }
const o = ownerOf(n);
const dd = d.trim() ? dataOf(fType.value, d) : { text: "…" };
const lines = [];
if (o.error) lines.push(h("div", { class: "msg err small" }, o.error));
if (dd.error) lines.push(h("div", { class: "msg err small" }, dd.error));
if (o.note) lines.push(h("div", { class: "msg warn small" }, o.note));
if (dd.note) lines.push(h("div", { class: "msg warn small" }, dd.note));
if (reverse && o.fqdn && fType.value === "PTR") {
const existing = current.filter((r) => r.type === "PTR" && r.name.replace(/\.$/, "") === o.fqdn && r.pending !== "delete");
if (existing.length) lines.push(h("div", { class: "msg warn small" }, "this address already has a PTR record (" + existing.map((r) => r.data).join(", ") + "); a second one gives it two names"));
}
mount(willAdd, o.fqdn && !o.error ? h("div", null, h("span", { class: "muted" }, "Will add: "),
h("code", null, o.fqdn + ". " + (fTTL.value !== "" ? fTTL.value + " " : "") + fType.value + " " + (dd.text || "")),
reverse && addressOf(o.fqdn) ? h("span", { class: "muted" }, " (address " + addressOf(o.fqdn) + ")") : null) : null, lines);
// In reverse zones, offer the names that already point to the address.
const addr = reverse && o.fqdn ? addressOf(o.fqdn) : "";
if (addr !== lookupFor) {
lookupFor = addr;
mount(suggest);
clearTimeout(lookupTimer);
if (addr && fType.value === "PTR") lookupTimer = setTimeout(async () => {
const hits = await get("/search", { q: addr }).catch(() => []);
if (lookupFor !== addr) return;
const names = [...new Set(hits.filter((x) => x.kind === "record").map((x) => x.label.split(" ")).filter((l) => (l[1] === "A" || l[1] === "AAAA") && l[2] === addr).map((l) => l[0]))];
mount(suggest, names.length ? [h("span", { class: "muted small" }, "Hosts with this address:"),
names.map((nm) => h("button", { type: "button", class: "chip", onclick: () => { fData.value = nm.replace(/\.$/, "") + "."; preview(); fData.focus(); } }, nm))]
: h("span", { class: "muted small" }, "No A/AAAA record points to " + addr + " yet. Tip: adding the A record with \"PTR\" ticked creates this PTR for you."));
}, 250);
}
};
for (const f of [fName, fData, fTTL]) f.addEventListener("input", preview);
const add = async (e) => {
e.preventDefault();
mount(err);
const body = { name: fName.value.trim() || "@", type: fType.value, data: fData.value.trim() };
const o = ownerOf(fName.value);
const dd = dataOf(fType.value, fData.value);
if (o.error || dd.error) { mount(err, h("div", { class: "msg err" }, o.error || dd.error)); (o.error ? fName : fData).focus(); return; }
const body = { name: reverse && o.relative ? o.relative : fName.value.trim() || "@", type: fType.value, data: fData.value.trim() };
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
if (fComment.value.trim()) body.comment = fComment.value.trim();
if (fPTR.input.checked) body.ptr = true;
@@ -80,16 +159,22 @@ async function recordsTab(el, zone, query) {
const r = await change("POST", "/zones/" + zone.id + "/records", body);
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
// Start the next record from scratch (the type stays, as records
// are often added several of a kind at a time).
fName.value = "";
fData.value = "";
fTTL.value = "";
fComment.value = "";
lookupFor = "";
preview();
await redraw();
fData.focus();
fName.focus();
} catch (ex) {
mount(err, errorBox(ex));
}
};
const form = h("form", { class: "inline-form", onsubmit: add },
h("div", { class: "f" }, h("label", null, "Name"), fName),
h("div", { class: "f" }, h("label", null, reverse ? "Address" : "Name"), fName),
h("div", { class: "f" }, h("label", null, "Type"), fType),
h("div", { class: "f" }, h("label", null, "TTL"), fTTL),
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
@@ -112,7 +197,7 @@ async function recordsTab(el, zone, query) {
const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value));
let prevName = null;
mount(list, table([
{ label: "Name", render: (r) => r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
{ label: "Name", render: (r) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
{ label: "Type", render: (r) => h("strong", null, r.type) },
{ label: "Data", render: (r) => r.data, tdClass: "data" },
@@ -186,10 +271,13 @@ async function recordsTab(el, zone, query) {
filter.addEventListener("input", draw);
typeFilter.addEventListener("change", draw);
mount(el,
h("div", { class: "panel" }, form, hint, err),
h("div", { class: "panel" },
reverse && rinfo ? h("div", { class: "muted small", style: { marginBottom: "6px" } }, "Reverse zone for " + rinfo.network + ": name records by address (e.g. " + rinfo.example + ") and give the host name as data.") : null,
form, willAdd, suggest, hint, err),
h("div", { class: "panel" },
h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter,
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute.")),
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). " +
(reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))),
list));
draw();
fName.focus();
+89 -7
View File
@@ -1,6 +1,7 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
import { get, change, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js";
export async function render(el, params, query) {
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]);
@@ -30,12 +31,24 @@ export async function render(el, params, query) {
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
h("div", { class: "panel" }, list));
draw();
if (query.get("new")) newZoneDialog(views, zones, settings);
if (query.get("new")) {
// Drop ?new=1 so re-rendering after the zone is created doesn't open
// the dialog again.
history.replaceState(null, "", "#/zones");
newZoneDialog(views, zones, settings);
}
}
export async function newZoneDialog(views, zones, settings) {
const err = h("div");
const name = input({ placeholder: "example.com or 192.168.1.0/24" });
// The zone's name matters most and is easy to get wrong, so the dialog
// asks for a domain or a network and shows the resulting zone name.
let kind = "forward";
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
const nameLabel = h("label", { for: "zone-name" }, "Domain");
name.id = "zone-name";
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], views.length === 1 ? views[0].name : "");
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
@@ -50,11 +63,69 @@ export async function newZoneDialog(views, zones, settings) {
const cloneSel = select([["", "– don't copy –"]], "");
const comment = input();
const updateClones = () => {
const n = name.value.trim().toLowerCase().replace(/\.$/, "") + ".";
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
};
name.addEventListener("input", updateClones);
// check validates the name as typed and returns the zone name (or null).
const check = () => {
const v = name.value.trim();
const msgs = [];
let zoneName = null;
if (type.value === "hint" && (v === "." || v === "")) {
mount(preview, h("div", null, "Zone name: ", h("strong", { class: "mono" }, "."), " (the root, for root hints)"));
return ".";
}
if (!v) { mount(preview, h("div", { class: "muted" }, kind === "reverse" ? "The zone name is generated from the network." : "Records in the zone are named relative to it: www becomes www.<zone>.")); return null; }
if (kind === "reverse") {
const r = reverseZoneFor(v);
if (r.error) msgs.push(["err", r.error]);
else zoneName = r.name;
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · answers \"which host has address …?\" for " + r.network)) : null,
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
} else {
const c = checkDomain(v);
c.problems.forEach((m) => msgs.push(["err", m]));
c.notes.forEach((m) => msgs.push(["warn", m]));
if (!c.problems.length) zoneName = c.name;
}
if (zoneName && kind === "forward") {
const fq = zoneName + ".";
const inView = (z) => (z.view || "") === (view.value || "");
const same = zones.find((z) => z.name === fq && inView(z));
if (same) { msgs.unshift(["err", "zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "") + ": open it from the zone list"]); zoneName = null; }
else {
const parent = zones.filter((z) => inView(z) && z.name !== "." && fq.endsWith("." + z.name)).sort((a, b) => b.name.length - a.name.length)[0];
if (parent) msgs.push(["info", "a child of zone " + parent.display_name + ": add NS records for " + fq.replace(/\.$/, "") + " there so other servers can find it"]);
const kids = zones.filter((z) => inView(z) && z.name.endsWith("." + fq));
if (kids.length) msgs.push(["info", "contains the existing zone" + (kids.length > 1 ? "s " : " ") + kids.map((z) => z.display_name).join(", ")]);
}
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · a record named www becomes www." + zoneName)) : null,
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
}
if (zoneName && kind === "reverse") {
const fq = zoneName + ".";
if (zones.find((z) => z.name === fq && (z.view || "") === (view.value || ""))) {
mount(preview, h("div", { class: "msg err small" }, "the reverse zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "")));
return null;
}
}
return zoneName;
};
const setKind = (k) => {
kind = k;
mount(err);
nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
check();
name.focus();
};
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
}
name.addEventListener("input", () => { mount(err); updateClones(); check(); });
view.addEventListener("change", check);
const rows = {
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
@@ -70,12 +141,17 @@ export async function newZoneDialog(views, zones, settings) {
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
};
const typeRows = h("div", { class: "form-grid" });
const drawType = () => mount(typeRows, rows[type.value] || []);
const drawType = () => {
mount(typeRows, rows[type.value] || []);
if (type.value === "hint" && !name.value.trim()) name.value = ".";
check();
};
type.addEventListener("change", drawType);
drawType();
const body = h("div", null,
h("div", { class: "form-grid" },
field("Name", name, "A domain name, or a network such as 192.168.1.0/24 to create its reverse zone."),
h("label", null, "Kind"), h("div", null, kindButtons),
nameLabel, h("div", null, name, preview),
views.length ? field("View", view) : null,
field("Type", type),
field("Comment", comment)),
@@ -85,7 +161,13 @@ export async function newZoneDialog(views, zones, settings) {
{ label: "Cancel", value: null },
{ label: "Create zone", kind: "primary", onclick: async () => {
const t = type.value;
const data = { name: name.value.trim(), type: t, comment: comment.value || undefined };
const zoneName = check();
if (!zoneName) {
mount(err, h("div", { class: "msg err" }, name.value.trim() ? "Fix the " + (kind === "reverse" ? "network" : "zone name") + " first." : "Give the " + (kind === "reverse" ? "network" : "domain") + " first."));
name.focus();
return false;
}
const data = { name: zoneName, type: t, comment: comment.value || undefined };
if (view.value) data.view = view.value;
const opts = [];
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");