Files
adamandClaude Opus 5.5 6cd3f6ef4e Reverse zones: other views' addresses aren't missing PTRs
A reverse zone's missing PTR count covers only its own view's A and
AAAA records. Addresses only other views' records use are counted apart
(ptr_other_views, 'Other views' in zone show, a note on the zone page)
and filled only when asked: fill-ptr --other-views, zone create
--fill --other-views, or the New reverse zone dialog's checkbox. This
keeps one view's host names out of another view's reverse zones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:40:05 +01:00

437 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// Off unless asked (or a network only other views use is picked): it
// publishes one view's host names in another.
const fillOther = checkbox("Also for addresses only other views' records use, named from there", false);
const fillRow = field("PTR records", h("div", null, fill, views.length ? fillOther : null), "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.<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);
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;
if ((c.hidden || "").startsWith("records in ")) { fill.input.checked = true; fillOther.input.checked = true; }
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 (fillOther.input.checked) data.fill_other_views = 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 = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="' +
(off ? "M5 7V5a3 3 0 0 1 5.8-1.1" : "M5 7V5a3 3 0 0 1 6 0v2") +
'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect x="3" y="7" width="10" height="7.5" rx="1.5" fill="currentColor"/></svg>';
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);
}