The zone page's breadcrumb (Zones › view) linked the view to a filtered zone list while the view link below it opened the view. It is now a single "← Zones" link back to the list with its filters, and a changeset has "← History". View links carry where they came from, so the views page offers "← example.org (external)" or "← Zones" (only the UI's own #/ addresses are accepted). Switching a zone's tabs no longer blanks the page: the header and tabs show at once and the previous tab stays, faded, until the next has loaded. The router shows "Loading…" only for pages that take a while. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
242 lines
15 KiB
JavaScript
242 lines
15 KiB
JavaScript
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList } from "../ui.js";
|
||
import { get, change, changeMany, 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")]);
|
||
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
||
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
||
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
||
const viewsById = new Map(views.map((v) => [v.id, v]));
|
||
const list = h("div");
|
||
const draw = () => {
|
||
const q = filter.value.trim().toLowerCase();
|
||
// Keep the filters in the address, so going back to the list (or a
|
||
// breadcrumb) restores them.
|
||
const qs = new URLSearchParams();
|
||
if (filter.value.trim()) qs.set("q", filter.value.trim());
|
||
if (viewSel.value) qs.set("view", viewSel.value);
|
||
if (typeSel.value) qs.set("type", typeSel.value);
|
||
const hash = "#/zones" + (qs.toString() ? "?" + qs : "");
|
||
if (location.hash !== hash && !query.get("new")) history.replaceState(null, "", hash);
|
||
lastList.zones = hash;
|
||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||
mount(list, table([
|
||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
||
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||
{ label: "Records", render: (z) => z.type === "primary" ? String(z.record_count) : "", class: "right", tdClass: "right" },
|
||
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
||
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
||
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => z.pending ? "pending-" + z.pending : "" }));
|
||
};
|
||
filter.addEventListener("input", draw);
|
||
viewSel.addEventListener("change", draw);
|
||
typeSel.addEventListener("change", draw);
|
||
mount(el,
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
|
||
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
|
||
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")) {
|
||
// 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");
|
||
// 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." });
|
||
const nsAddr = textarea("", { class: "mono", rows: 2, placeholder: "ns1 192.0.2.1" });
|
||
const contact = input({ value: settings.rname ? emailOf(settings.rname) : "", placeholder: "hostmaster@example.com" });
|
||
const ttl = input({ type: "number", value: settings.default_ttl || 3600, min: 0 });
|
||
const primaries = textarea("", { class: "mono", rows: 2, placeholder: "192.0.2.53\n2001:db8::53 key xfer-key" });
|
||
const forwarders = textarea("", { class: "mono", rows: 2, placeholder: "192.168.1.1\n127.0.0.1 port 8600" });
|
||
const forwardMode = select(["only", "first"], "only");
|
||
const file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" });
|
||
const inView = select(views.map((v) => [v.name, v.name]), "");
|
||
const cloneSel = select([["", "– don't copy –"]], "");
|
||
const comment = input();
|
||
const updateClones = () => {
|
||
// Records can only be copied into a single zone.
|
||
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") + "]")));
|
||
};
|
||
// check validates the name as typed and returns the zone name (or null);
|
||
// a network that needs several reverse zones gives a list of names.
|
||
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 || r.names;
|
||
const single = /\/(32|128)$/.test(r.network || "");
|
||
const what = single ? " · answers \"which host has address " + r.network.replace(/\/\d+$/, "") + "?\" and nothing else in its network"
|
||
: " · answers \"which host has address …?\" for " + r.network;
|
||
mount(preview,
|
||
typeof zoneName === "string" ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, what)) : null,
|
||
Array.isArray(zoneName) ? h("div", null,
|
||
h("div", null, "Reverse names follow the dots, so " + r.network + " is covered by " + zoneName.length + " zones, created together:"),
|
||
h("div", { class: "mono small" }, zoneName.join(", ")),
|
||
h("div", { class: "muted small" }, "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 existing = [zoneName].flat().filter((n) => zones.find((z) => z.name === n + "." && (z.view || "") === (view.value || "")));
|
||
if (existing.length) {
|
||
mount(preview, h("div", { class: "msg err small" }, "the reverse zone" + (existing.length > 1 ? "s " : " ") + existing.join(", ") + (existing.length > 1 ? " already exist" : " 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)."),
|
||
field("SOA contact", contact, "Email address of the zone's administrator."),
|
||
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
|
||
field("Copy records from", cloneSel, "For split-horizon setups: start with the records of this zone in another view.")],
|
||
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
|
||
stub: [field("Primary servers", primaries, "One per line.")],
|
||
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
|
||
forward: [field("Forwarders", forwarders, "One per line: address [port N]. Leave empty to disable forwarding for this domain."), field("Forward", forwardMode, "only: never resolve these names directly; first: try the forwarders, then resolve normally.")],
|
||
"static-stub": [field("Server addresses", primaries, "One per line.")],
|
||
hint: [field("File", file, "Root hints file, e.g. /usr/share/dns/root.hints.")],
|
||
"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] || []);
|
||
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" },
|
||
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)),
|
||
h("div", { style: { marginTop: "8px" } }, typeRows),
|
||
err);
|
||
await modal("New zone", body, [
|
||
{ label: "Cancel", value: null },
|
||
{ label: "Create zone", kind: "primary", onclick: async () => {
|
||
const t = type.value;
|
||
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(" ");
|
||
if (t === "primary") {
|
||
data.nameservers = lines(ns.value);
|
||
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
||
data.default_ttl = Number(ttl.value) || undefined;
|
||
if (cloneSel.value) { data.clone_from = Number(cloneSel.value); delete data.nameservers; }
|
||
data.records = lines(nsAddr.value).map((l) => {
|
||
const [n, a] = l.split(/\s+/);
|
||
return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a };
|
||
});
|
||
} else if (t === "secondary" || t === "stub" || t === "mirror") {
|
||
if (lines(primaries.value).length) opts.push({ name: "primaries", value: "{ " + list(primaries) + " }" });
|
||
} else if (t === "static-stub") {
|
||
opts.push({ name: "server-addresses", value: "{ " + list(primaries) + " }" });
|
||
} else if (t === "forward") {
|
||
opts.push({ name: "forwarders", value: "{ " + list(forwarders) + " }" });
|
||
opts.push({ name: "forward", value: forwardMode.value });
|
||
} else if (t === "hint") {
|
||
data.file = file.value.trim();
|
||
} else if (t === "in-view") {
|
||
data.in_view = inView.value;
|
||
}
|
||
if (opts.length) data.options = opts;
|
||
try {
|
||
const r = Array.isArray(zoneName)
|
||
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
|
||
: await change("POST", "/zones", data);
|
||
toast(describeResult(r));
|
||
rerender();
|
||
return true;
|
||
} catch (e) {
|
||
mount(err, errorBox(e));
|
||
return false;
|
||
}
|
||
} },
|
||
], { wide: true, sticky: true });
|
||
}
|
||
|
||
export function emailOf(rname) {
|
||
if (!rname) return "";
|
||
const s = rname.replace(/\.$/, "");
|
||
const m = s.match(/^((?:[^.\\]|\\.)+)\.(.+)$/);
|
||
if (!m) return s;
|
||
return m[1].replace(/\\\./g, ".") + "@" + m[2];
|
||
}
|
||
|
||
export function pendingBadge(kind) {
|
||
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
||
}
|