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"; 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 list = h("div"); const draw = () => { const q = filter.value.trim().toLowerCase(); 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) => z.view || h("span", { class: "muted" }, "–") } : 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")) 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" }); 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 = () => { const n = 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); 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] || []); 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."), 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 data = { name: name.value.trim(), 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 = 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"); }