import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural, confirmDialog } from "../ui.js"; import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js"; import { rerender } from "../app.js"; import { checkDomain, reverseZoneFor, maskNetwork } from "../names.js"; export async function render(el, params, query) { const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"), get("/reverse-zones/suggestions", draftQuery()).catch(() => [])]); 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.prefix ? h("span", { class: "muted small" }, " " + z.prefix) : null, z.ptr_missing ? [" ", badge(z.ptr_missing + " PTR missing", "warn")] : null, dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, 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), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", 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 : "", z.disabled ? "disabled" : ""].filter(Boolean).join(" ") })); }; 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", { title: "A zone for the PTR records of a network: Jarl lists the networks your records use", onclick: () => newZoneDialog(views, zones, settings, { kind: "reverse" }) }, "New reverse zone"), " ", h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone")] : null)), h("div", { class: "panel" }, list), suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null); 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); } } // preset.view picks the view (opened from a view's page), preset.kind // "reverse" starts with a reverse zone. export async function newZoneDialog(views, zones, settings, preset = {}) { 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 = preset.kind === "reverse" ? "reverse" : "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" }); // For a reverse zone: sizes to pick from, and the networks the records // use without a reverse zone, in every view (docs/reverse.md). const helper = h("div", { class: "reverse-helper" }); // The networks per view picked ("" for all views): a chosen view's list // also has the addresses only other views' records use. const candsBy = {}; let cands = null; const candQ = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { marginBottom: "6px" } }); candQ.addEventListener("input", () => drawHelper()); const loadCands = () => { const k = view.value || ""; if (candsBy[k]) { cands = candsBy[k]; return drawHelper(); } get("/reverse-zones/suggestions", draftQuery({ all: 1, view: k || undefined })).catch(() => []).then((c) => { candsBy[k] = c; if ((view.value || "") === k) { cands = c; drawHelper(); } }); }; // sizeFrom is the address the size buttons work from: a record's, once a // network was picked, so a /48 made smaller becomes that record's /64, // not the /48's first one. let sizeFrom = null; 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])], preset.view || (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 linkBox = checkbox("Keep them linked: shared records stay in step, each view can add its own", true); const cloneWrap = h("div", null, cloneSel, linkBox); const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; }; cloneSel.addEventListener("change", syncLink); const comment = input(); const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true); const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself."); 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") + "]"))); syncLink(); }; // 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..")); 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); syncFill(); if (k === "reverse") loadCands(); drawHelper(); if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –"; 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)); } const changed = () => { mount(err); updateClones(); check(); drawHelper(); }; const typed = () => { sizeFrom = null; changed(); }; const drawHelper = () => { if (kind !== "reverse") return mount(helper); const v = name.value.trim(); const isNet = v && maskNetwork(v, 32) !== null; const v6 = v.includes(":"); const bits = Number(v.split("/")[1]); const sizes = isNet ? h("div", { class: "size-chips" }, h("span", { class: "muted small" }, "Size: "), (v6 ? [[48, "a whole site (all its /64s)"], [56, "a smaller delegation"], [64, "one subnet"], [128, "one address"]] : [[16, "a /16"], [24, "one network"], [32, "one address"]]) .map(([b, title]) => h("button", { type: "button", class: "small" + (bits === b ? " active" : ""), title, onclick: () => { name.value = maskNetwork(sizeFrom || name.value, b); changed(); } }, "/" + b)), sizeFrom ? h("span", { class: "muted small" }, " around " + sizeFrom) : null) : null; const q = candQ.value.trim().toLowerCase(); // Yours first, then the networks with the most records. const list = (cands || []).filter((c) => !q || [c.prefix, c.zone, c.view, c.hidden, ...c.samples].join(" ").toLowerCase().includes(q)) .sort((a, b) => (a.group === "yours" ? 0 : 1) - (b.group === "yours" ? 0 : 1) || b.fill - a.fill); const shown = list.slice(0, 15); mount(helper, sizes, (cands || []).length > 8 ? candQ : null, shown.length ? h("div", { class: "cands" }, h("div", { class: "muted small" }, "Networks your records use without a reverse zone" + (view.value ? " in view " + view.value : "") + ": pick one, then change its size if you need"), shown.map((c) => h("button", { type: "button", class: "cand", title: c.why, onclick: () => { name.value = c.prefix; sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null; if (views.length && c.view) view.value = c.view; changed(); } }, h("span", { class: "mono" }, c.prefix), views.length && !view.value ? h("span", { class: "muted" }, " " + c.view) : null, h("span", { class: "muted small" }, " " + c.fill + " PTR" + (c.samples.length ? " · " + c.samples.slice(0, 2).join(", ") : "")), c.hidden ? [" ", badge(c.hidden, "off")] : null)), list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more: search to find one") : null) : q && cands && cands.length ? h("div", { class: "muted small" }, "No network matches " + JSON.stringify(candQ.value.trim()) + ".") : cands && !list.length ? h("div", { class: "muted small" }, "Every address your records use" + (view.value ? " in view " + view.value : "") + " has a reverse zone.") : null); }; name.addEventListener("input", typed); view.addEventListener("change", () => { check(); if (kind === "reverse") loadCands(); else drawHelper(); }); const glueRow = field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."); const cloneRow = field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones)."); const rows = { primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."), glueRow, field("SOA contact", contact, "Email address of the zone's administrator."), field("Default TTL", ttl, "Seconds, used by records without their own TTL."), cloneRow], 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 syncFill = () => { for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none"; // Glue and copying a zone from another view are for domains. for (const n of [...glueRow, ...cloneRow]) if (n) n.style.display = kind === "reverse" ? "none" : ""; }; const drawType = () => { syncFill(); 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", { class: "grid-cell" }, name, preview, helper), views.length ? field("View", view) : null, field("Type", type), fillRow, field("Comment", comment)), h("div", { style: { marginTop: "8px" } }, typeRows), err); if (kind === "reverse") setKind("reverse"); await modal(kind === "reverse" ? "New reverse zone" : "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" && kind === "reverse" && fill.input.checked) data.fill = true; 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) { const src = zones.find((z) => z.id === Number(cloneSel.value)); if (linkBox.input.checked && src && src.view) data.link_with = src.view; else 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)); (r.warnings || []).forEach((w) => toast(w, "warn", 10000)); 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]; } // dnssecLock marks a signed zone with a padlock linking to its DNSSEC tab: // closed while signed, open while signing is being turned off (BIND's // insecure policy). export function dnssecLock(z) { if (!z.dnssec_policy) return null; const off = z.dnssec_policy === "insecure"; const a = h("a", { class: "dnssec-lock" + (off ? " off" : ""), href: "#/zones/" + z.id + "/dnssec", title: off ? "DNSSEC: signing is being turned off" : "DNSSEC: signed (policy " + z.dnssec_policy + ")", "aria-label": off ? "DNSSEC being turned off" : "DNSSEC signed", }); a.innerHTML = ''; return a; } export function pendingBadge(kind) { return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn"); } // suggestPanel lists the reverse zones the private views lack for their // records' addresses, to create filled with PTR records (docs/reverse.md): // networks that are yours, and public addresses, which may be a web // host's, to create one by one or dismiss. function suggestPanel(sugg, views) { const create = async (list) => { const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } })); try { const r = reqs.length === 1 ? await change("POST", "/zones", reqs[0].data) : await changeMany(reqs, plural(reqs.length, "reverse zone")); toast(describeResult(r)); (r.warnings || []).forEach((w) => toast(w, "warn", 10000)); rerender(); } catch (e) { toast(e.message, "err", 8000); } }; const dismiss = async (s) => { try { const r = await change("POST", "/reverse-zones/dismiss", { view_id: s.view_id || undefined, prefix: s.prefix }); toast(describeResult(r)); rerender(); } catch (e) { toast(e.message, "err", 8000); } }; const cols = (pub) => [ views.length ? { label: "View", render: (s) => s.view } : null, { label: pub ? "Address" : "Network", render: (s) => [h("span", { class: "mono" }, pub ? s.prefix.replace(/\/(32|128)$/, "") : s.prefix), pub ? null : h("div", { class: "muted small" }, s.why)] }, { label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) }, { label: pub ? "Name" : "PTR records", render: (s) => pub ? h("span", { class: "mono small" }, s.samples.map((x) => x.split(" ")[1]).join(", ")) : [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] }, { label: "", render: (s) => [h("button", { class: "small", onclick: () => create([s]) }, "Create"), pub ? [" ", h("button", { class: "small", title: "Not yours (a web host's, say): don't suggest it again", onclick: () => dismiss(s) }, "Dismiss")] : null], tdClass: "actions-cell" }, ].filter(Boolean); const yours = sugg.filter((s) => s.group === "yours"); const pub = sugg.filter((s) => s.group !== "yours"); return h("div", { class: "panel" }, h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"), yours.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(yours) }, "Create all " + yours.length)) : null), h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."), yours.length ? table(cols(false), yours) : null, pub.length ? publicGroup(pub, cols(true), create) : null); } // publicGroup lists the public addresses, collapsed: there can be hundreds // (a web host's), so it has a search, shows 25 at a time, and dismisses // what the search found at once. function publicGroup(pub, cols, create) { const PAGE = 25; let limit = PAGE; const q = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { width: "300px" } }); const body = h("div"); const match = () => { const t = q.value.trim().toLowerCase(); return pub.filter((s) => !t || [s.prefix, s.zone, s.view, ...s.samples].join(" ").toLowerCase().includes(t)); }; const dismissAll = async (list) => { const byView = new Map(); for (const s of list) { const k = s.view_id || 0; if (!byView.has(k)) byView.set(k, []); byView.get(k).push(s.prefix); } if (!await confirmDialog("Dismiss " + plural(list.length, "address", "addresses"), "Jarl stops suggesting reverse zones for " + (list.length === 1 ? list[0].prefix : list.length + " public addresses" + (q.value.trim() ? " matching " + JSON.stringify(q.value.trim()) : "")) + ". Bring one back with: jarl zone suggest-reverse --undismiss NETWORK --view VIEW.", "Dismiss")) return; try { const ops = [...byView].map(([vid, prefixes]) => ({ op: "reverse.dismiss", data: { view_id: vid || undefined, prefixes } })); toast(describeResult(await changeMany(ops, "dismiss " + plural(list.length, "reverse zone suggestion")))); rerender(); } catch (e) { toast(e.message, "err", 8000); } }; const draw = () => { const list = match(); const shown = list.slice(0, limit); mount(body, h("div", { class: "compare-controls" }, q, list.length ? h("button", { class: "small", title: "Not yours: stop suggesting all of these", onclick: () => dismissAll(list) }, list.length === pub.length ? "Dismiss all " + list.length : "Dismiss these " + list.length) : null), shown.length ? table(cols, shown) : h("div", { class: "muted small" }, "No address matches."), list.length > shown.length ? h("button", { class: "small", onclick: () => { limit += PAGE; draw(); } }, "Show " + Math.min(PAGE, list.length - shown.length) + " more (of " + (list.length - shown.length) + ")") : null); }; q.addEventListener("input", () => { limit = PAGE; draw(); }); draw(); return h("details", { class: "compare-only", open: pub.length <= 10 }, h("summary", null, h("strong", null, "Public addresses: " + pub.length), h("span", { class: "muted small" }, " · create a zone only for an address that is yours; dismiss the others (a web host's)")), h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's. Each gets a zone of its own: the rest of its network belongs to others."), body); }