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:
1 parent
3b7bee67f8
commit
bf7f99874b
19 files changed
+577
-35
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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; }
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user