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

+6
View File
@@ -116,6 +116,12 @@ func Owner(input, zone string) (string, error) {
if in == "" || in == "@" {
return zone, nil
}
// In reverse zones an address stands for its PTR name.
if IsReverse(zone) {
if addr, err := netip.ParseAddr(strings.TrimSuffix(in, ".")); err == nil {
return ReverseName(addr), nil
}
}
a, err := ToASCII(in)
if err != nil {
return "", err
+13
View File
@@ -172,3 +172,16 @@ func TestMisc(t *testing.T) {
t.Error("IsSubdomain")
}
}
func TestOwnerAddressInReverseZone(t *testing.T) {
for _, tc := range []struct{ in, zone, want string }{
{"10.10.0.20", "0.10.10.in-addr.arpa.", "20.0.10.10.in-addr.arpa."},
{"20", "0.10.10.in-addr.arpa.", "20.0.10.10.in-addr.arpa."},
{"2001:db8::1", "8.b.d.0.1.0.0.2.ip6.arpa.", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."},
{"10.10.0.20", "adbraun.lol.", "10.10.0.20.adbraun.lol."}, // not special in forward zones
} {
if got, err := Owner(tc.in, tc.zone); err != nil || got != tc.want {
t.Errorf("Owner(%q, %q) = %q, %v; want %q", tc.in, tc.zone, got, err, tc.want)
}
}
}
+12
View File
@@ -450,3 +450,15 @@ func TestZoneCreateWithOptions(t *testing.T) {
t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o)
}
}
func TestParseWarningsReachTheResult(t *testing.T) {
env := newEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.10.0.0/24")}))
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "0.10.10.in-addr.arpa", Name: model.Ptr("10.10.0.20"), Type: model.Ptr("PTR"), Data: model.Ptr("nas.example.com")}))
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "read as the absolute name") {
t.Errorf("warnings = %q", res.Warnings)
}
if len(res.Ops) != 1 || !strings.Contains(res.Ops[0].Summary, "20.0.10.10.in-addr.arpa. IN PTR nas.example.com.") {
t.Errorf("ops = %+v", res.Ops)
}
}
+12
View File
@@ -528,6 +528,15 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
}
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
// The stored operation holds the canonical data, so applying it doesn't
// notice again.
func (x *expander) parseWarnings(owner string, p *rr.Parsed) {
for _, w := range p.Warnings {
x.warnings = append(x.warnings, fmt.Sprintf("%s %s: %s", dnsname.Display(owner), p.Type, w))
}
}
func (x *expander) recordCreate(req Request) ([]model.Op, error) {
var in RecordInput
if err := decode(req.Data, &in); err != nil {
@@ -544,6 +553,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
if err != nil {
return nil, err
}
x.parseWarnings(owner, p)
ptr := in.PTR
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
@@ -589,6 +599,7 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if err != nil {
return nil, err
}
x.parseWarnings(owner, p)
if in.Name != nil {
in.Name = &owner
}
@@ -737,6 +748,7 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
if err != nil {
return nil, err
}
x.parseWarnings(owner, p)
canon = append(canon, p.Data)
}
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
+13
View File
@@ -202,6 +202,16 @@ func Parse(zone, owner, typ, data string) (*Parsed, error) {
if IsTextType(t) && !strings.HasPrefix(d, `"`) {
d = QuoteText(d)
}
// A PTR record names a host, which is never inside the reverse zone
// itself: "nas.adbraun.lol" means the absolute name.
var ptrWarning string
if t == "PTR" && dnsname.IsReverse(zone) && !strings.HasSuffix(d, ".") && !strings.ContainsAny(d, " \t") {
if !strings.Contains(d, ".") {
return nil, fmt.Errorf("PTR data %q would become %s%s; give the host's full name, e.g. %s.example.com", d, d+".", zone, d)
}
ptrWarning = fmt.Sprintf("%q was read as the absolute name %q (add a trailing dot to be explicit)", d, d+".")
d += "."
}
typeText := t
if _, known := dns.TypeToString[code]; !known {
typeText = fmt.Sprintf("TYPE%d", code)
@@ -226,6 +236,9 @@ func Parse(zone, owner, typ, data string) (*Parsed, error) {
return nil, fmt.Errorf("data does not form a %s record", t)
}
p := &Parsed{RR: parsed, Type: t}
if ptrWarning != "" {
p.Warnings = append(p.Warnings, ptrWarning)
}
if err := p.fixNames(zone, data); err != nil {
return nil, err
}
+18
View File
@@ -157,3 +157,21 @@ func TestParseErrorText(t *testing.T) {
}
}
}
func TestPTRTargetsInReverseZones(t *testing.T) {
zone := "0.10.10.in-addr.arpa."
p, err := Parse(zone, "20."+zone, "PTR", "nas.adbraun.lol")
if err != nil || p.Data != "nas.adbraun.lol." || len(p.Warnings) != 1 {
t.Errorf("unqualified PTR target: %v %+v", err, p)
}
if p, err := Parse(zone, "20."+zone, "PTR", "nas.adbraun.lol."); err != nil || p.Data != "nas.adbraun.lol." || len(p.Warnings) != 0 {
t.Errorf("qualified PTR target: %v %+v", err, p)
}
if _, err := Parse(zone, "20."+zone, "PTR", "nas"); err == nil {
t.Error("a single-label PTR target inside the reverse zone was accepted")
}
// Forward zones keep zone-file semantics.
if p, err := Parse("adbraun.lol.", "x.adbraun.lol.", "CNAME", "nas"); err != nil || p.Data != "nas.adbraun.lol." {
t.Errorf("forward CNAME: %v %+v", err, p)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Address match list helpers: suggestions for the ACLs, TSIG keys and
// built-in names that can be used where named expects an address match
// list (options such as allow-query, view match-clients, ACL elements),
// and a check for names that don't exist, so nobody has to switch pages
// to look up an ACL's exact name.
import { h, mount } from "./ui.js";
import { get } from "./api.js";
export const builtins = ["any", "none", "localhost", "localnets"];
// amlContext loads the names that can be referred to. Users who may not
// read ACLs or keys simply get fewer suggestions.
export async function amlContext() {
const [acls, keys] = await Promise.all([get("/acls").catch(() => []), get("/tsig-keys").catch(() => [])]);
return { acls: acls.map((a) => ({ name: a.name, detail: a.elements.join("; ") })), keys: keys.map((k) => k.name) };
}
const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i;
// checkElement returns a problem with one element, or "".
function checkElement(line, ctx, exclude) {
let e = line.trim().replace(/;$/, "").trim();
if (!e) return "";
if (e.startsWith("!")) e = e.slice(1).trim();
const lower = e.toLowerCase();
if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return "";
const key = e.match(/^key\s+"?([^"\s]+)"?$/i);
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
if (/\s/.test(e)) return "";
const name = e.replace(/^"|"$/g, "");
if (exclude && name.toLowerCase() === exclude.toLowerCase()) return "an ACL can't include itself";
return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)");
}
// suggestions adds clickable names below a text field. mode "lines" adds
// one element per line (and checks the names); "inline" inserts
// "name; " at the cursor in named.conf syntax.
export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
const problems = h("div", { class: "aml-problems" });
const insert = (text) => {
if (mode === "lines") {
const lines = field.value.split("\n").map((l) => l.trim()).filter(Boolean);
if (!lines.includes(text)) lines.push(text);
field.value = lines.join("\n");
} else {
const at = field.selectionStart ?? field.value.length;
const before = field.value.slice(0, at), after = field.value.slice(at);
const sep = before && !/[\s{]$/.test(before) ? " " : "";
field.value = before + sep + text + "; " + after;
field.selectionStart = field.selectionEnd = (before + sep + text + "; ").length;
}
field.focus();
field.dispatchEvent(new Event("input"));
};
const chip = (label, text, title) => h("button", { type: "button", class: "chip", title: title || "", onclick: () => insert(text) }, label);
const acls = ctx.acls.filter((a) => a.name.toLowerCase() !== exclude.toLowerCase());
const row = h("div", { class: "aml-suggest" },
acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null,
ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null,
h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])),
mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it.") : null);
if (mode === "lines") {
const check = () => {
const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m);
mount(problems, msgs.map(([l, m]) => h("div", { class: "msg warn small" }, l + ": " + m)));
};
field.addEventListener("input", check);
check();
}
return h("div", { class: "aml" }, row, problems);
}
+152
View File
@@ -0,0 +1,152 @@
// DNS name helpers for forms: reverse zones from networks, PTR names from
// addresses, and plain checks of domain names, so the UI can show what a
// name will become before anything is submitted. The server checks
// everything again; these only make mistakes visible early.
const v4Re = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
export function parseIPv4(s) {
const m = s.trim().match(v4Re);
if (!m) return null;
const b = m.slice(1, 5).map(Number);
return b.every((x) => x <= 255) ? b : null;
}
// parseIPv6 returns the 32 hex nibbles of an address, or null.
export function parseIPv6(s) {
s = s.trim().toLowerCase();
if (!s.includes(":") || !/^[0-9a-f:.]+$/.test(s)) return null;
let tail = [];
const v4 = s.match(/:(\d+\.\d+\.\d+\.\d+)$/);
if (v4) {
const b = parseIPv4(v4[1]);
if (!b) return null;
tail = [((b[0] << 8) | b[1]).toString(16), ((b[2] << 8) | b[3]).toString(16)];
s = s.slice(0, -v4[1].length) + "0:0";
}
const halves = s.split("::");
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(":") : [];
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
let groups;
if (halves.length === 2) {
const fill = 8 - head.length - rest.length;
if (fill < 1) return null;
groups = [...head, ...Array(fill).fill("0"), ...rest];
} else groups = head;
if (tail.length) groups.splice(-2, 2, ...tail);
if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
return groups.map((g) => g.padStart(4, "0")).join("").split("");
}
// reverseZoneFor turns a network such as 10.10.0.0/24 into its reverse
// zone. It returns {name} or {error}.
export function reverseZoneFor(cidr) {
const [addr, bitsText] = cidr.trim().split("/");
if (bitsText === undefined) return { error: "give the network with its prefix length, e.g. 10.10.0.0/24" };
const bits = Number(bitsText);
const b4 = parseIPv4(addr);
if (b4) {
if (![8, 16, 24].includes(bits)) return { error: "IPv4 reverse zones follow the dots: use /8, /16 or /24 (a /" + bits + " network goes in the enclosing /" + (bits > 24 ? 24 : bits > 16 ? 16 : 8) + " zone)" };
const n = bits / 8;
const host = b4.slice(n);
if (host.some((x) => x !== 0)) return { error: "that's an address inside the network; the network is " + b4.slice(0, n).concat(host.map(() => 0)).join(".") + "/" + bits };
return { name: b4.slice(0, n).reverse().join(".") + ".in-addr.arpa", network: b4.slice(0, n).join(".") + (n < 4 ? "." + Array(4 - n).fill(0).join(".") : "") + "/" + bits };
}
const n6 = parseIPv6(addr);
if (n6) {
if (!(bits > 0 && bits <= 124 && bits % 4 === 0)) return { error: "IPv6 reverse zones need a prefix that is a multiple of 4, e.g. /48 or /64" };
const keep = n6.slice(0, bits / 4);
if (n6.slice(bits / 4).some((x) => x !== "0")) return { error: "the address has bits set after /" + bits + "; use the network address" };
return { name: keep.slice().reverse().join(".") + ".ip6.arpa", network: cidr.trim() };
}
return { error: "not an IPv4 or IPv6 network" };
}
export function isReverseZone(name) {
const n = name.replace(/\.$/, "").toLowerCase();
return n.endsWith(".in-addr.arpa") || n.endsWith(".ip6.arpa");
}
// reverseInfo describes the network a reverse zone covers.
export function reverseInfo(zoneName) {
const n = zoneName.replace(/\.$/, "").toLowerCase();
if (n.endsWith(".in-addr.arpa")) {
const labels = n.slice(0, -".in-addr.arpa".length).split(".").reverse();
if (labels.length > 3 || labels.some((l) => !/^\d+$/.test(l))) return null;
const pad = Array(4 - labels.length).fill("0");
return { family: 4, prefix: labels, network: [...labels, ...pad].join(".") + "/" + labels.length * 8, example: [...labels, ...pad.map((_, i) => (i === pad.length - 1 ? "20" : "0"))].join(".") };
}
if (n.endsWith(".ip6.arpa")) {
const nib = n.slice(0, -".ip6.arpa".length).split(".").reverse();
const full = [...nib, ...Array(32 - nib.length).fill("0")];
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(full.slice(i, i + 4).join("").replace(/^0+(?=.)/, ""));
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: groups.slice(0, 4).join(":") + "::20" };
}
return null;
}
// ptrOwner turns an address (or a relative label) into the record name
// inside a reverse zone: {relative, fqdn} or {error}.
export function ptrOwner(input, zoneName) {
const zone = zoneName.replace(/\.$/, "");
const info = reverseInfo(zone);
const s = input.trim();
if (!info || !s) return {};
const b4 = info.family === 4 ? parseIPv4(s) : null;
const n6 = info.family === 6 ? parseIPv6(s) : null;
if (b4 || n6) {
const parts = b4 ? b4.map(String) : n6;
const inZone = info.prefix.every((p, i) => parts[i] === p);
if (!inZone) return { error: s + " is not in " + info.network + ", the network this zone covers" };
const rel = parts.slice(info.prefix.length).reverse().join(".");
return { relative: rel, fqdn: rel + "." + zone };
}
if (s.endsWith(".")) return { relative: s, fqdn: s.replace(/\.$/, "") };
return { relative: s, fqdn: s + "." + zone };
}
// addressOf rebuilds the address a PTR name in a reverse zone stands for.
export function addressOf(fqdn) {
const n = fqdn.replace(/\.$/, "").toLowerCase();
if (n.endsWith(".in-addr.arpa")) {
const l = n.slice(0, -".in-addr.arpa".length).split(".");
return l.length === 4 && l.every((x) => /^\d+$/.test(x)) ? l.reverse().join(".") : "";
}
if (n.endsWith(".ip6.arpa")) {
const l = n.slice(0, -".ip6.arpa".length).split(".");
if (l.length !== 32) return "";
const hex = l.reverse().join("");
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(hex.slice(i, i + 4).replace(/^0+(?=.)/, ""));
return groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::");
}
return "";
}
// checkDomain checks a zone name as typed and returns {name, problems,
// notes}: name is the normalized form (lowercase, no final dot).
export function checkDomain(input) {
const raw = input.trim();
const name = raw.toLowerCase().replace(/\.$/, "");
const problems = [], notes = [];
if (!name) return { name, problems, notes };
if (/\s/.test(name)) problems.push("names can't contain spaces");
if (/^[a-z]+:\/\//.test(name) || name.includes("/")) problems.push("give just the domain name, not a URL or path");
if (name.includes("@")) problems.push("that looks like an email address");
if (parseIPv4(name) || parseIPv6(name)) problems.push("that's an address: for a reverse zone choose \"Reverse zone\" and give the network");
const labels = name.split(".");
if (labels.some((l) => l === "")) problems.push("empty label (two dots in a row)");
if (labels.some((l) => l.length > 63)) problems.push("each part can be at most 63 characters");
if (name.length > 253) problems.push("the name is too long");
if (!problems.length && labels.some((l) => !/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?$/.test(l) && !/^xn--/.test(l))) {
if (/[^\x00-\x7f]/.test(name)) notes.push("international name: it will be stored in its xn-- (punycode) form");
else problems.push("use letters, digits and hyphens (no hyphen at the start or end of a part)");
}
if (raw !== name && raw.replace(/\.$/, "") !== raw.replace(/\.$/, "").toLowerCase()) notes.push("stored in lowercase");
if (!problems.length && labels.length === 1 && name !== "") notes.push("a single label: that's a top-level domain; usually you want your domain, e.g. " + name + "." + "example");
if (!problems.length && /^(www|mail|ns\d*|smtp|vpn)\./.test(name) && labels.length > 2) notes.push("\"" + labels[0] + "\" usually names a host inside a zone; is the zone " + labels.slice(1).join(".") + "?");
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
return { name, problems, notes };
}
+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(" ");
+19
View File
@@ -89,6 +89,7 @@ header.top {
}
header.top .brand { font-weight: 700; font-size: 17px; letter-spacing: 0.5px; color: var(--text); }
header.top .brand span { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 6px; }
header.top .brand:hover { text-decoration: none; color: var(--accent); }
nav.main { display: flex; gap: 2px; flex-wrap: wrap; }
nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); }
nav.main a:hover { background: var(--info-soft); text-decoration: none; }
@@ -343,3 +344,21 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.hm-cell { height: 16px; }
.chart-grid { grid-template-columns: 1fr; }
}
/* Address match list suggestions. */
.aml-suggest { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin-top: 6px; }
.aml-suggest .muted { margin-left: 4px; }
.aml-suggest .muted:first-child { margin-left: 0; }
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
.aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; }
/* Zone name and record previews. */
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
.segmented button:last-child { border-right: none; }
.segmented button:hover { background: var(--info-soft); }
.segmented button.active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
.name-preview { margin-top: 6px; font-size: 13px; }
.name-preview .msg, .will-add .msg { margin: 4px 0 0; padding: 3px 8px; }
.will-add { margin-top: 8px; font-size: 13px; }
.will-add code { font-family: var(--mono); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; word-break: break-all; }
+4 -2
View File
@@ -131,8 +131,10 @@ export async function confirmDialog(title, message, okLabel = "OK", kind = "prim
// field returns a label + control pair for .form-grid layouts.
export function field(label, control, help) {
const id = control.id || ("f" + Math.random().toString(36).slice(2, 9));
control.id = id;
// 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];
}