Files
adamandClaude Opus 5.5 2fa9828bcf Reverse zones: list other views' addresses, clearer wording
The zone page and jarl zone show list the addresses only other views'
records use (ptr_other_views_list), with the name each would get and the
view it comes from, and say plainly that they aren't missing here. The
page's button reads 'Add PTR records for them in view X'. zone show
lists the missing PTRs too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00

976 lines
59 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, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList, plural } from "../ui.js";
import { api, get, stream, change, changeMany, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js";
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
import { dnssecTab } from "./dnssec.js";
import { registrarTab } from "./registrar.js";
let renderSeq = 0;
// tabIn is the "/tab" part of the address of zone o (an in_views entry)
// that keeps the tab open now, when o has that tab.
function tabIn(o, tab) {
if (!tab || tab === "registrar") return "";
if (["records", "file", "dnssec", "compare"].includes(tab) && o.type !== "primary") return "";
if (tab === "servers" && !["primary", "secondary", "mirror"].includes(o.type)) return "";
return "/" + tab;
}
export async function render(el, params, query) {
const id = params.id;
const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]);
const view = views.find((v) => v.id === zone.view_id);
const tab = params.tab || (zone.type === "primary" ? "records" : "settings");
const tabs = [
zone.type === "primary" ? ["records", "Records (" + zone.record_count + ")"] : null,
["settings", "Settings"],
["options", "Options"],
zone.type === "primary" ? ["file", "Zone file"] : null,
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
zone.type === "primary" ? ["dnssec", "DNSSEC"] : null,
zone.other_views ? ["compare", "Compare views"] : null,
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
].filter(Boolean);
const body = h("div");
const head = h("div", { class: "page-head" },
h("div", { class: "grow" },
backLink("Zones", lastList.zones || "#/zones"),
h("h1", { class: "mono" }, zone.display_name),
h("div", { class: "muted" },
badge(zone.type, "accent"), " ",
zone.pending ? [pendingBadge(zone.pending), " "] : null,
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.in_views ? [h("span", { title: "The zone " + zone.display_name + " in another view" }, "also in ",
zone.in_views.map((o, i) => {
const v = views.find((x) => x.name === o.view);
return [i ? ", " : "", h("a", { href: "#/zones/" + o.id + tabIn(o, params.tab), class: v && v.color ? "vc-" + v.color : null,
title: "Go to " + zone.display_name + " in view " + o.view + (o.type !== zone.type ? " (" + o.type + ")" : "") + (o.linked ? ", linked with this one" : "") }, o.view),
o.type !== zone.type ? h("span", { class: "muted" }, " (" + o.type + ")") : null];
})), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null,
zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "ok")), " "] : null,
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
zone.links ? [h("span", { class: "badge accent" }, "linked with ",
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + tabIn({ type: zone.type }, params.tab), title: "The zone in view " + l.view }, l.view)])), " "] : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" },
zone.disabled
? h("button", { onclick: () => toggleZone(zone) }, "Enable zone")
: h("button", { disabled: !!zone.dnssec_policy, title: zone.dnssec_policy ? "A signed zone can't be disabled: turn signing off first" : "Stop serving the zone, keeping it in Jarl", onclick: () => toggleZone(zone) }, "Disable zone"),
" ",
zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null,
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
const banner = zone.disabled ? h("div", { class: "msg warn" },
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.")
: zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" },
plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ",
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them")),
h("ul", { class: "small ptr-missing" }, (zone.ptr_missing_list || []).map((f) => h("li", null,
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null,
f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)),
zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null))
: null;
// Addresses only other views have records for: not missing here, but
// they can be added (publishing those views' names in this one).
const others = zone.ptr_other_views_list || [];
const fromViews = [...new Set(others.map((f) => f.from))];
const otherNote = !zone.disabled && zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" },
plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " " + (zone.ptr_other_views === 1 ? "has" : "have") + " A or AAAA records only in view " + fromViews.join(", ") +
", not in view " + zone.view + ", so " + (zone.ptr_other_views === 1 ? "it isn't" : "they aren't") + " counted as missing here. ",
h("button", { class: "small", title: "Clients of view " + zone.view + " will then get these names, which come from view " + fromViews.join(", "), onclick: () => fillPTR(zone, true) },
"Add PTR records for " + (zone.ptr_other_views === 1 ? "it" : "them") + " in view " + zone.view),
h("ul", { class: "small ptr-missing" }, others.map((f) => h("li", null,
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
fromViews.length > 1 ? h("span", { class: "muted" }, " (view " + f.from + ")") : null)),
zone.ptr_other_views > others.length ? h("li", { class: "muted" }, "and " + (zone.ptr_other_views - others.length) + " more") : null)) : null;
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
// The header and tabs show at once. Below them, switching tabs keeps the
// previous tab's content (faded) until the new one is loaded, rather
// than blanking the page; opening the zone shows "Loading…" there.
const seq = ++renderSeq;
const prev = el.querySelector(`:scope > .zone-body[data-zone="${id}"]`);
const holder = prev || h("div", { class: "zone-body" }, h("div", { class: "muted" }, "Loading…"));
holder.classList.add("stale");
body.className = "zone-body";
body.dataset.zone = id;
mount(el, head, banner, otherNote, tabBar, holder);
if (tab === "records") await recordsTab(body, zone, query);
else if (tab === "settings") await settingsTab(body, zone);
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
else if (tab === "file") await fileTab(body, zone);
else if (tab === "servers") await serversTab(body, zone);
else if (tab === "dnssec") await dnssecTab(body, zone);
else if (tab === "registrar") await registrarTab(body, zone);
else if (tab === "compare") await compareTab(body, zone, query);
// A later click (another tab) has taken over the page.
if (seq !== renderSeq || !holder.isConnected) return;
holder.replaceWith(body);
const first = body.querySelector("[data-autofocus]");
if (first) first.focus();
}
// ---- comparing a zone across views
// compareTab shows the names and types that differ between the views that
// have the zone, with a button to share one view's version (linked zones).
// compareTab compares the zone with the same zone in other views, from
// this one (docs/views.md#comparing-views): a summary, what the views
// answer differently, the names only some views have, and how much is
// the same. ?name= (and &type=) narrows it to one name.
async function compareTab(el, zone, query) {
const others = zone.other_views || [];
// A linked zone starts with a view it is linked to: that is the
// comparison that matters (its differences are its own records).
const linked = (zone.linked_with || []).filter((v) => others.includes(v));
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || linked[0] || others[0] || "");
const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } });
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
let showSame = false;
const out = h("div");
const one = (d) => h("div", { class: "oneline mono small", title: d }, d);
const draw = async () => {
const q = { with: withSel.value || undefined, name: nameIn.value.trim() || undefined, type: nameIn.value.trim() ? typeIn.value.trim() || undefined : undefined, all: showSame ? 1 : undefined };
let r;
try { r = await get("/zones/" + zone.id + "/compare", draftQuery(q)); } catch (e) { mount(out, errorBox(e)); return; }
const oneName = !!q.name;
const parts = [];
parts.push(h("p", { class: "muted" }, r.linked
? "The zones are linked: shared records are kept in step, so a difference is a view's own records, there on purpose."
: "The zones aren't linked: nothing keeps them in step, so a difference may be one that crept in. Link them (Link… above) to keep what is the same in step."));
if (!oneName) parts.push(h("ul", { class: "compare-summary" }, r.summary.map((s) => h("li", null,
h("strong", null, s.view), ": ",
[[s.different, "answered differently"], [s.only_base, "only in " + r.base], [s.only_other, "only in " + s.view], [s.same, "the same"]]
.map(([n, what], i) => [i ? " · " : "", h("span", { class: n && i < 3 ? "" : "muted" }, n + " " + what)])))));
if (r.different.length) {
parts.push(h("h3", null, "Answered differently (" + r.different.length + ")"),
h("div", { class: "table-wrap" }, h("table", { class: "compare" },
h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)))),
h("tbody", null, r.different.map((row) => h("tr", null,
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
row.views.map((vs) => h("td", { class: "compare-cell" },
vs.records.length ? vs.records.map(one) : h("span", { class: "muted" }, "–"),
vs.records.length ? h("div", { class: "compare-actions" },
vs.linked === "local" ? [badge("own records", "accent"), " "] : null,
h("button", { class: "small", title: "Every view gets " + vs.view + "'s records for " + row.name + " " + row.type, onclick: () => useEverywhere(r, row, vs) }, "Use this everywhere")) : null))))))));
}
for (const o of r.only) {
const missing = r.views.filter((v) => !o.views.includes(v));
parts.push(h("details", { class: "compare-only", open: oneName || o.rows.length <= 8 },
h("summary", null, h("strong", null, "Only in " + o.views.join(" and ")), " (" + o.rows.length + ")",
h("span", { class: "muted small" }, " · " + missing.join(", ") + (missing.length > 1 ? " have" : " has") + " no records of these names and types")),
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, o.rows.map((row) => {
const src = row.views.find((vs) => vs.records.length);
return h("tr", null, h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
h("td", { class: "compare-cell" }, src.records.map(one)),
h("td", { class: "actions-cell" }, missing.map((v) => h("button", { class: "small", title: "Give view " + v + " the same records (its own, local to it)", onclick: () => copyTo(row, src, row.views.find((x) => x.view === v)) }, "Add to " + v))));
}))))));
}
if (r.same_rows && r.same_rows.length) {
parts.push(h("h3", null, "The same in " + r.views.join(", ") + " (" + r.same_rows.length + ")"),
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, r.same_rows.map((row) => h("tr", { class: "muted" },
h("td", { class: "mono" }, row.name), h("td", null, row.type), h("td", { class: "compare-cell" }, row.views[0].records.map(one))))))));
} else if (!oneName && r.same) {
parts.push(h("p", { class: "muted" }, r.same + " names and types are the same. ", h("a", { href: "#", onclick: (e) => { e.preventDefault(); showSame = true; draw(); } }, "List them")));
}
if (oneName && !r.different.length && !r.only.length && !(r.same_rows || []).length) parts.push(h("div", { class: "empty" }, "No records of that name in these views."));
else if (!r.different.length && !r.only.length && !oneName) parts.push(h("div", { class: "empty" }, "Nothing differs."));
mount(out, parts);
};
// useEverywhere gives every view one view's records of a name and type:
// in a linked zone by sharing them, otherwise by setting each zone's.
const useEverywhere = async (r, row, vs) => {
const rest = row.views.filter((x) => x !== vs);
const linkedAll = r.linked && zone.linked_with;
const msg = linkedAll
? "View " + vs.view + "'s " + row.name + " " + row.type + " records become shared: every linked view (" + [zone.view, ...zone.linked_with].join(", ") + ") answers " + vs.data.join(", ") + ", and their own records of that name and type are replaced."
: rest.map((x) => x.view).join(" and ") + " get" + (rest.length > 1 ? "" : "s") + " " + vs.data.join(", ") + " for " + row.name + " " + row.type + ", replacing what " + (rest.length > 1 ? "they have" : "it has") + ".";
if (!await confirmDialog("Use " + vs.view + "'s " + row.name + " " + row.type, msg, "Use this everywhere")) return;
try {
const res = linkedAll
? await change("PATCH", "/records/" + vs.ids[0], { local: false })
: await changeMany(rest.map((x) => ({ op: "rrset.replace", data: rrsetFrom(row, vs, x) })), "use " + vs.view + "'s " + row.name + " " + row.type);
toast(describeResult(res));
await draw();
} catch (e) { toast(e.message, "err", 8000); }
};
const copyTo = async (row, src, dst) => {
try {
toast(describeResult(await changeMany([{ op: "rrset.replace", data: rrsetFrom(row, src, dst) }], "add " + row.name + " " + row.type + " to view " + dst.view)));
await draw();
} catch (e) { toast(e.message, "err", 8000); }
};
const go = () => draw();
withSel.addEventListener("change", go);
nameIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
typeIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
mount(el, h("div", { class: "panel" },
h("div", { class: "compare-controls" },
h("label", null, "Compare " + zone.view + " with "), withSel, " ",
h("label", null, " Name "), nameIn, typeIn, " ",
h("button", { class: "small", onclick: go }, "Compare"),
nameIn.value ? [" ", h("button", { class: "small", onclick: () => { nameIn.value = ""; typeIn.value = ""; go(); } }, "All names")] : null),
out));
await draw();
}
// rrsetFrom is the request giving zone dst a name and type's records from
// src: its own (local) records, in a linked zone.
function rrsetFrom(row, src, dst) {
const d = { zone_id: dst.zone_id, name: row.fqdn, type: row.type, data: src.data, local: true };
if (src.ttl !== undefined && src.ttl !== null) d.ttl = src.ttl;
return d;
}
// ---- linked zones (docs/views.md#linked-zones)
// linkButton links the zone with the same zone in other views, or takes
// it out of its link.
function linkButton(zone) {
if (zone.linked_with) {
return h("button", { title: "Stop keeping this zone in step with " + zone.linked_with.join(", ") + "; its records stay, all local", onclick: async () => {
if (!await confirmDialog("Unlink zone", "Take " + zone.display_name + " in view " + zone.view + " out of its link with " + zone.linked_with.join(", ") + "? Its records stay as they are, all local to this view; changes no longer reach the other views.", "Unlink")) return;
try {
toast(describeResult(await change("POST", "/zones/" + zone.id + "/unlink")));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
} }, "Unlink");
}
return h("button", { title: "Keep this zone's records in step with the same zone in other views", onclick: () => linkDialog(zone) }, "Link…");
}
async function linkDialog(zone) {
const zones = await get("/zones");
const others = zones.filter((z) => z.name === zone.name && z.id !== zone.id && z.type === "primary" && z.view);
if (!others.length) {
await modal("Link zone", h("p", null, "No other view has a zone " + zone.display_name + ". Create it there linked: New zone, the same name, \"Copy records from\" this one with \"Keep them linked\" ticked."));
return;
}
const picked = new Set();
const err = h("div");
let linkBtn = null;
const rows = others.map((z) => {
const box = h("input", { type: "checkbox", "aria-label": "Link with view " + z.view });
box.addEventListener("change", () => {
if (box.checked) picked.add(z.view); else picked.delete(z.view);
tr.classList.toggle("selected", box.checked);
if (linkBtn) {
linkBtn.disabled = picked.size === 0;
// Zones already linked with a picked one join too: name them.
const all = new Set(picked);
for (const o of others) if (picked.has(o.view)) (o.linked_with || []).forEach((v) => all.add(v));
all.delete(zone.view);
linkBtn.textContent = picked.size ? "Link with " + [...all].join(", ") : "Link";
}
});
const tr = h("tr", { class: "pick", onclick: (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } } },
h("td", null, box),
h("td", null, h("strong", null, z.view)),
h("td", { class: "muted" }, z.record_count + " records"),
h("td", { class: "small" }, z.linked_with ? "already linked with " + z.linked_with.join(", ") + " (joins too)" : ""));
return tr;
});
const body = h("div", null,
h("p", null, "Linked zones keep their shared records in step: a change in one view reaches the others in the same change. Each view can still have records of its own, which override the shared ones of that name and type there, and keeps its own options."),
h("p", { class: "muted small" }, "Linking changes no answer: records that are the same in every view become shared; anything that differs stays local to each view. Share one later from the view whose version is right."),
h("h3", null, "Link view " + zone.view + " with"),
h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, rows))),
err);
const open = modal("Link " + zone.display_name, body, [
{ label: "Cancel", value: null },
{ label: "Link", kind: "primary", onclick: async () => {
if (!picked.size) return false;
try {
const r = await change("POST", "/zones/" + zone.id + "/link", { with: [...picked] });
toast(describeResult(r));
(r.warnings || []).forEach((m) => toast(m, "warn", 12000));
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { wide: true });
// Nothing is ticked at first: Link waits for a choice.
linkBtn = [...document.querySelectorAll(".modal .buttons button.primary")].pop();
if (linkBtn) linkBtn.disabled = true;
await open;
}
// ---- name servers: serials on the other servers, and the delegation
const nsStatus = {
self: ["this server", ""], in_sync: ["in sync", "ok"], answered: ["answers", "ok"],
behind: [null, "warn"], ahead: [null, "warn"], not_authoritative: ["doesn't serve the zone", "err"],
refused: ["refused", "err"], unreachable: ["no answer", "err"], no_route: ["no route from here", ""],
no_address: ["no address", "err"], error: ["error", "err"],
};
function nsBadge(r) {
let [text, kind] = nsStatus[r.status] || [r.status, "err"];
if (r.status === "behind") text = "behind by " + -r.diff;
if (r.status === "ahead") text = "ahead by " + r.diff;
return [badge(text, kind), r.error && r.status !== "unreachable" ? h("div", { class: "muted small" }, r.error) : null];
}
function nsTable(servers, withSource) {
return table([
{ label: "Server", render: (r) => r.name ? display(r.name) : h("span", { class: "muted" }, "–"), tdClass: "mono" },
{ label: "Address", render: (r) => (r.address || "–") + (r.port ? " port " + r.port : ""), tdClass: "mono small" },
withSource ? { label: "From", render: (r) => r.source, tdClass: "small" } : null,
{ label: "Status", render: nsBadge },
{ label: "Serial", render: (r) => r.serial ?? "–", tdClass: "mono" },
{ label: "Time", render: (r) => r.rtt_ms ? r.rtt_ms + " ms" : "", tdClass: "small muted" },
].filter(Boolean), servers, { empty: "No other name servers to ask." });
}
// Name server checks ask other servers over the network, which can take
// seconds. Results are kept for a while (per zone, for this page load),
// so coming back to the tab shows them at once; "Check again" asks anew.
const nsCache = new Map(); // zone id -> {serials, delegation}: {at, resp, log, resolver} once done, {job, resolver} while running
const nsFresh = 10 * 60 * 1000;
function nsEntry(zone) {
if (!nsCache.has(zone.id)) nsCache.set(zone.id, {});
return nsCache.get(zone.id);
}
// runCheck starts a check ("serials" or "delegation"), streamed: its log
// fills with the queries as they are sent and answered. Views subscribe
// to the job; its result is kept in nsCache.
function runCheck(zone, kind, query) {
const job = { log: [], subs: new Set(), done: null, error: null };
const entry = nsEntry(zone);
const resolver = query.resolver || "";
entry[kind] = { job, resolver };
const notify = () => job.subs.forEach((f) => f());
stream("/zones/" + zone.id + "/" + kind, { ...query, stream: 1 }, (ev) => {
if (ev.kind === "start") job.log.push({ id: ev.id, query: ev.query, started: Date.now() });
else if (ev.kind === "end") {
const q = job.log.find((q) => q.id === ev.id && !q.ended);
if (q) Object.assign(q, { ended: Date.now(), result: ev.result, answer: ev.answer, error: ev.error, rtt: ev.rtt_ms });
} else if (ev.kind === "done") job.done = ev.response;
notify();
}).then(() => {
if (job.done) entry[kind] = { at: Date.now(), resp: job.done, log: job.log, resolver };
else job.error = new Error("the check ended without a result");
notify();
}).catch((e) => {
job.error = e;
if (entry[kind] && entry[kind].job === job) delete entry[kind];
notify();
});
return job;
}
// followCheck shows a running check in el, drawing progress(job) until
// it's done and then result(response, log). It stops following once el
// has been shown and is gone (the check goes on, and its result is kept).
function followCheck(el, job, progress, result) {
let seen = false, timer = null;
const stop = () => { job.subs.delete(draw); clearInterval(timer); timer = null; };
function draw() {
if (el.isConnected) seen = true;
else if (seen) { stop(); return; }
if (job.error) mount(el, errorBox(job.error));
else if (job.done) mount(el, result(job.done, job.log));
else mount(el, progress(job));
if (job.done || job.error) stop();
}
job.subs.add(draw);
draw();
if (!job.done && !job.error) {
// Tick the "waiting n s" counters.
timer = setInterval(() => {
if (seen && !el.isConnected) { stop(); return; }
for (const s of el.querySelectorAll(".elapsed")) s.textContent = "waiting " + ((Date.now() - Number(s.dataset.started)) / 1000).toFixed(1) + " s";
}, 200);
}
}
// queryLog lists the queries of a check: what was sent, and the answer or
// that it's still waiting.
function queryLog(log) {
const secs = (ms) => (ms / 1000).toFixed(1) + " s";
return h("ul", { class: "query-log" }, log.map((q) => {
let outcome;
if (!q.ended) outcome = [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted elapsed", dataset: { started: q.started } }, "waiting " + secs(Date.now() - q.started))];
else {
// The server's round-trip time when it has one; else how long the
// answer took to arrive here (at least the timeouts, for errors).
const ms = q.rtt || q.ended - q.started;
const took = h("span", { class: "muted" }, ms < 1 ? "<1 ms" : ms < 1000 ? ms + " ms" : secs(ms));
if (q.result) outcome = [nsBadge(q.result), took];
else if (q.error) outcome = [h("span", { class: "err-text" }, q.error), took];
else outcome = [h("span", { class: "mono" }, "→ " + (q.answer || "done")), took];
}
return h("li", { class: q.ended ? "" : "pending" }, h("code", null, q.query), " ", outcome);
}));
}
// serialsView fills el with the serials check: the kept result, or the
// running check's progress until its result is in.
function serialsView(el, zone, again) {
const c = nsEntry(zone).serials;
if (!again && c && c.resp && Date.now() - c.at < nsFresh) {
mount(el, serialsResult(c.resp, c.log, true));
return;
}
const job = !again && c && c.job ? c.job : runCheck(zone, "serials", {});
followCheck(el, job, (job) => {
const local = job.log.find((q) => q.id === 0);
return [
h("p", null, "This server: serial ", local && !local.ended ? [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted" }, "asking named")] : h("span", { class: "muted" }, "…")),
h("p", { class: "muted small" }, job.log.length ? "Waiting for the servers to answer:" : "Finding the servers to ask…"),
queryLog(job.log)];
}, (resp, log) => serialsResult(resp, log, false));
}
// delegationView fills el with the delegation check, like serialsView.
// It starts a check only when asked (run), or follows one that's running.
function delegationView(el, zone, resolver, run) {
const c = nsEntry(zone).delegation;
if (!run) {
if (c && c.job) followCheck(el, c.job, delegationProgress, (resp, log) => delegationResult(resp, log, false));
else if (c && c.resp && Date.now() - c.at < nsFresh) mount(el, delegationResult(c.resp, c.log, true, c.at));
return;
}
const job = runCheck(zone, "delegation", resolver ? { resolver } : {});
followCheck(el, job, delegationProgress, (resp, log) => delegationResult(resp, log, false));
}
function delegationProgress(job) {
return [
h("p", { class: "muted small" }, job.log.length
? "Finding the parent zone, asking its servers for the delegation and the DS records, then each delegated server for the zone:"
: [h("span", { class: "spinner", "aria-hidden": "true" }), "Starting…"]),
queryLog(job.log)];
}
function delegationResult(r, log, cached, at) {
return [
delegationReport(r),
log && log.length ? h("details", { class: "query-details" }, h("summary", { class: "small muted" }, "Queries sent (" + log.length + ")"), queryLog(log)) : null,
h("p", { class: "muted small" }, "Checked ", time(r.checked_at || new Date(at).toISOString()), cached ? " (kept from earlier; Check delegation to ask anew)." : "."),
];
}
function serialsResult(r, log, cached) {
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
return [
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
nsTable(r.servers, true),
r.notes.map((n) => h("p", { class: "muted small" }, n)),
log && log.length ? h("details", { class: "query-details" }, h("summary", { class: "small muted" }, "Queries sent (" + log.length + ")"), queryLog(log)) : null,
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), cached ? " (kept from earlier; Check again to ask anew)." : "."),
];
}
async function serversTab(el, zone) {
const serials = h("div");
const again = h("button", { class: "small", onclick: () => serialsView(serials, zone, true) }, "Check again");
const parts = [h("div", { class: "panel" },
h("div", { class: "page-head" },
h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"),
again),
h("p", { class: "muted small" }, zone.type === "primary"
? "Asks the servers in the zone's NS records and its also-notify targets which serial they serve. A secondary that is behind hasn't picked up the latest change yet."
: "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."),
serials)];
if (zone.type === "primary") {
const out = h("div");
const entry = nsEntry(zone);
const resolver = input({ placeholder: "the server's own", value: entry.delegation ? entry.delegation.resolver : "" });
const run = (e) => {
e.preventDefault();
delegationView(out, zone, resolver.value.trim(), true);
};
delegationView(out, zone, "", false);
parts.push(h("div", { class: "panel" },
h("h2", null, "Delegation from the parent zone"),
h("p", { class: "muted small" }, "For zones on the internet: checks that the parent zone (for adbraun.lol, \"lol\") points to this zone's name servers with the right glue addresses, and that each of them serves the zone. A zone used only on your own network isn't delegated, and doesn't need this."),
h("form", { class: "inline-form", onsubmit: run },
h("div", { class: "f" }, h("label", null, "Resolver (optional)"), resolver),
h("div", { class: "f" }, h("label", null, " "), h("button", { type: "submit" }, "Check delegation"))),
out));
}
mount(el, parts);
// Don't wait for the answers: the tab shows the check's progress.
serialsView(serials, zone, false);
}
function delegationReport(r) {
const lvl = { ok: "ok", info: "", warning: "warn", error: "err" };
const parents = {};
for (const p of r.parent_servers) {
const say = p.status === "delegated" ? "delegate to " + p.ns.map(display).join(", ") : p.status === "not_delegated" ? "no delegation" : p.status + (p.error ? ": " + p.error : "");
(parents[say] = parents[say] || []).push(display(p.name));
}
return [
r.parent ? h("p", null, "Parent zone ", h("strong", { class: "mono" }, display(r.parent)), h("span", { class: "muted" }, " (found through " + r.resolver + ")")) : null,
Object.keys(parents).length ? h("ul", { class: "small" }, Object.entries(parents).map(([say, who]) =>
h("li", null, who.length > 1 ? who[0] + " and " + (who.length - 1) + " more: " : who[0] + ": ", say))) : null,
h("div", null, r.findings.map((f) => h("div", { class: "msg " + lvl[f.level] }, f.message))),
r.glue.length ? table([
{ label: "Glue for", render: (g) => display(g.name), tdClass: "mono" },
{ label: "Parent", render: (g) => g.parent.join(", ") || "none", tdClass: "mono small" },
{ label: "Zone", render: (g) => g.jarl.join(", ") || "none", tdClass: "mono small" },
{ label: "", render: (g) => g.match ? badge("matches", "ok") : badge("differs", "warn") },
], r.glue) : null,
r.servers.length ? [h("h3", null, "Delegated name servers"), nsTable(r.servers, false)] : null,
r.notes.map((n) => h("p", { class: "muted small" }, n)),
];
}
async function deleteZone(zone) {
const msg = zone.type === "primary"
? "Delete zone " + zone.display_name + " and its " + zone.record_count + " records? named stops serving it once applied."
: "Delete zone " + zone.display_name + "?";
if (!(await confirmDialog("Delete zone", msg, "Delete zone", "danger"))) return;
try {
const r = await change("DELETE", "/zones/" + zone.id);
toast(describeResult(r));
location.hash = "#/zones";
} catch (e) { toast(e.message, "err", 8000); }
}
// ---- records
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");
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", "data-autofocus": true });
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 linked = !!zone.linked_with;
const fLocal = checkbox("Only here", false, { title: "Add it to view " + zone.view + " only, overriding the shared records of this name and type here (otherwise it reaches " + (zone.linked_with || []).join(", ") + " too)" });
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 = 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", zone.auto_ptr || !(fType.value === "A" || fType.value === "AAAA"));
};
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);
// aliasTo fills the add form with a CNAME to a record's name: only the
// alias's name is left to type.
const aliasTo = (r) => {
fType.value = "CNAME";
showHint();
fData.value = r.relative_name === "@" ? "@" : r.relative_name;
fName.value = "";
fTTL.value = "";
fComment.value = "";
preview();
form.scrollIntoView({ block: "nearest", behavior: "smooth" });
fName.placeholder = "alias for " + r.name.replace(/\.$/, "");
fName.focus();
};
const add = async (e) => {
e.preventDefault();
mount(err);
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;
if (linked && fLocal.input.checked) body.local = true;
try {
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 = "";
fName.placeholder = reverse && rinfo ? rinfo.example : "@ or www";
fData.value = "";
fTTL.value = "";
fComment.value = "";
lookupFor = "";
preview();
await redraw();
fName.focus();
} catch (ex) {
mount(err, errorBox(ex));
}
};
const form = h("form", { class: "inline-form", onsubmit: add },
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),
h("div", { class: "f" }, h("label", null, "Comment"), fComment),
h("div", { class: "f" }, h("label", null, " "), fPTR),
linked ? h("div", { class: "f" }, h("label", null, " "), fLocal) : null,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record")));
// Filters and table
const filter = input({ type: "search", placeholder: "Filter by name or data…", value: query.get("q") || "" });
const typeFilter = select([["", "All types"], ...[...new Set(records.map((r) => r.type))].sort()], "");
// Shown once the zone has disabled records.
const stateFilter = select([["", "Enabled and disabled"], ["on", "Enabled only"], ["off", "Disabled only"]], "");
const list = h("div");
let editing = null;
let current = records;
const redraw = async () => {
current = await get("/zones/" + zone.id + "/records", draftQuery());
draw();
};
const draw = () => {
const q = filter.value.trim().toLowerCase();
const off = current.filter((r) => r.disabled).length;
stateFilter.style.display = off || stateFilter.value ? "" : "none";
stateFilter.options[2].textContent = "Disabled only (" + off + ")";
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) &&
(!stateFilter.value || !!r.disabled === (stateFilter.value === "off")));
mount(list, table([
{ label: "Name", render: (r) => [reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name,
r.disabled && r.pending !== "disable" ? [" ", badge("disabled", "off")] : null], tdClass: "name" },
{ 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" },
linked ? { label: "Views", render: (r) => r.linked === "local"
? badge("only " + zone.view, "accent")
: h("span", { class: "muted small", title: "Shared: the same in " + [zone.view, ...zone.linked_with].join(", ") + " (unless a view overrides it)" }, "shared") } : null,
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
linked ? [r.linked === "local"
? h("button", { class: "small reveal", title: "Make this view's " + r.relative_name + " " + r.type + " the shared version: it replaces the other views' (" + zone.linked_with.join(", ") + ")", onclick: () => setLocal(r, false) }, "Share")
: h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null,
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
zone.other_views && !(r.type === "NS" && r.relative_name === "@") ? [h("a", { class: "button small reveal", href: "#/zones/" + zone.id + "/compare?name=" + encodeURIComponent(r.relative_name) + "&type=" + r.type, title: "Compare " + r.relative_name + " " + r.type + " with view" + (zone.other_views.length > 1 ? "s " : " ") + zone.other_views.join(", ") }, "Compare"), " "] : null,
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
r.disabled
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
: h("button", { class: "small toggle", title: "Leave the record out of the zone, but keep it here", onclick: () => toggle(r) }, "Disable"), " ",
h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" },
].filter(Boolean), rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [
r.pending ? "pending-" + r.pending : "",
r.disabled ? "disabled" : "",
// A record followed by more of the same name: a faint line, so the
// full line marks where the next name starts.
rows[i + 1] && rows[i + 1].name === r.name ? "same-name-next" : "",
].filter(Boolean).join(" ") }));
if (editing) mountEdit();
};
const del = async (r) => {
const isAddr = r.type === "A" || r.type === "AAAA";
let ptr = false;
if (isAddr && zone.auto_ptr) {
if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data + ", and its PTR record (this zone keeps them in step)", "Delete", "danger"))) return;
} else if (isAddr) {
const c = checkbox("Also delete the matching PTR record", true);
const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
if (ok !== true) return;
ptr = c.input.checked;
} else if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data, "Delete", "danger"))) return;
try {
const res = await change("DELETE", "/records/" + r.id, undefined, { query: ptr ? { ptr: 1 } : undefined });
toast(describeResult(res));
await redraw();
} catch (e) { toast(e.message, "err", 8000); }
};
// toggle disables or enables a record, and the PTR record that goes
// with an address record.
const toggle = async (r) => {
const verb = r.disabled ? "enable" : "disable";
const ptr = r.type === "A" || r.type === "AAAA";
try {
const res = await change("POST", "/records/" + r.id + "/" + verb, undefined, { query: ptr ? { ptr: 1 } : undefined });
toast(describeResult(res));
await redraw();
} catch (e) { toast(e.message, "err", 8000); }
};
const setLocal = async (r, local) => {
if (!local && !(await confirmDialog("Share " + r.relative_name + " " + r.type, "View " + zone.view + "'s " + r.relative_name + " " + r.type + " records become the shared version, in " + zone.linked_with.join(", ") + " too: the records there of that name and type are replaced.", "Share"))) return;
try {
const res = await change("PATCH", "/records/" + r.id, { local });
toast(describeResult(res));
await redraw();
} catch (e) { toast(e.message, "err", 8000); }
};
const edit = (r) => { editing = r; draw(); };
const mountEdit = () => {
const r = editing;
const eName = input({ value: r.relative_name, class: "mono", style: { width: "100%" } });
const eType = select(types.map((t) => t.type), r.type);
const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } });
const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } });
const eComment = input({ value: r.comment || "", style: { width: "100%" } });
const ePTR = checkbox("update PTR", false, { title: "Keep the PTR record in the reverse zone in step: moved with a new name or address, or added if it is missing" });
const eErr = h("div");
const save = async () => {
const body = {};
const name = eName.value.trim() || "@";
if (name !== r.relative_name) body.name = name;
if (eType.value !== r.type) body.type = eType.value;
const shown = r.type === "TXT" && r.text !== undefined ? r.text : r.data;
if (eData.value.trim() !== shown || body.type) body.data = eData.value.trim();
if (eTTL.value === "" && r.ttl !== undefined && r.ttl !== null) body.ttl = null;
else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value);
if (eComment.value !== (r.comment || "")) body.comment = eComment.value;
if (ePTR.input.checked) body.ptr = true;
// "update PTR" alone adds a missing PTR record (the reverse zone may
// be newer than the record).
if (Object.keys(body).length === 0) { editing = null; draw(); return; }
try {
const res = await change("PATCH", "/records/" + r.id, body);
toast(describeResult(res));
(res.warnings || []).forEach((w) => toast(w, "warn", 8000));
editing = null;
await redraw();
} catch (e) { mount(eErr, errorBox(e)); }
};
const row = h("div", { class: "panel", style: { background: "var(--accent-soft)" } },
h("h3", null, "Edit record #" + r.id),
h("div", { class: "inline-form" },
h("div", { class: "f" }, h("label", null, "Name"), eName),
h("div", { class: "f" }, h("label", null, "Type"), eType),
h("div", { class: "f" }, h("label", null, "TTL"), eTTL),
h("div", { class: "f grow" }, h("label", null, "Data"), eData),
h("div", { class: "f" }, h("label", null, "Comment"), eComment),
(r.type === "A" || r.type === "AAAA") && !zone.auto_ptr ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))),
h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."),
eErr);
list.prepend(row);
eData.focus();
};
filter.addEventListener("input", draw);
typeFilter.addEventListener("change", draw);
stateFilter.addEventListener("change", draw);
mount(el,
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, stateFilter,
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();
}
// ---- settings
// inZone reports whether an absolute name is the zone or inside it.
function inZone(name, zoneName) {
if (!name) return true;
return name === zoneName || name.endsWith("." + zoneName);
}
async function settingsTab(el, zone) {
const views = await get("/views");
const err = h("div");
const rows = [];
const s = zone.soa || {};
// The server reads a name without a final dot as relative to the zone,
// so a name outside it keeps its dot.
const mnameText = inZone(s.mname, zone.name) ? display(s.mname) : (s.mname || "");
const mname = input({ value: mnameText, class: "mono" });
const rname = input({ value: zone.rname_email || emailOf(s.rname) });
const refresh = input({ type: "number", value: s.refresh });
const retry = input({ type: "number", value: s.retry });
const expire = input({ type: "number", value: s.expire });
const minimum = input({ type: "number", value: s.minimum });
const ttl = input({ type: "number", value: zone.default_ttl });
const policy = select([["", "(server default)"], "date", "increment", "unixtime"], zone.serial_policy || "");
const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || ""));
const file = input({ value: zone.file || "", class: "mono" });
const comment = textarea(zone.comment, { rows: 2 });
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
if (zone.type === "primary") {
rows.push(
field("Primary name server", mname, "SOA MNAME. A name outside " + zone.display_name + " ends with '.'; without it, the name is taken as relative to the zone."),
field("Contact", rname, "SOA RNAME, as an email address."),
field("Refresh", refresh, "Seconds between secondaries checking for updates."),
field("Retry", retry, "Seconds before a failed refresh is retried."),
field("Expire", expire, "Seconds after which secondaries stop answering without a successful refresh."),
field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."),
field("Default TTL", ttl, "TTL for records that don't set their own."),
field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes."));
if (!zone.prefix && !/\.(in-addr|ip6)\.arpa\.$/.test(zone.name)) rows.push(field("Reverse DNS", autoPTR,
"Adding, changing, disabling or deleting an address record does the same to its PTR record, in the reverse zones of " + (zone.view ? "view " + zone.view + (zone.linked_with ? " and the linked views" : "") : "the server") + ". An address keeps the name it has; one without a reverse zone is left alone (Zones lists the reverse zones you're missing)."));
}
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones)."));
if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it."));
rows.push(field("Comment", comment));
const save = async () => {
mount(err);
const body = { comment: comment.value };
if (zone.type === "primary") {
body.soa = { rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
if (mname.value.trim() !== mnameText) body.soa.mname = mname.value.trim();
body.default_ttl = +ttl.value;
body.serial_policy = policy.value;
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
}
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim();
if (views.length) body.view_id = view.value ? Number(view.value) : 0;
try {
const r = await change("PATCH", "/zones/" + zone.id, body);
toast(describeResult(r));
rerender();
} catch (e) {
if (e.status === 400 && /^nothing to (change|do)/.test(e.message)) toast("Nothing changed");
else mount(err, errorBox(e));
}
};
mount(el, h("div", { class: "panel" }, h("div", { class: "form-grid" }, rows), err,
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: save }, "Save settings"))));
}
// ---- zone file
async function fileTab(el, zone) {
const text = await get("/zones/" + zone.id + "/file", draftQuery());
const imp = textarea("", { class: "mono", rows: 10, placeholder: "$TTL 3600\nwww IN A 192.0.2.10\n..." });
const mode = select([["merge", "merge: add records that are missing"], ["replace", "replace: make the zone equal to this text"]], "merge");
const useSOA = checkbox("Also take SOA timers and $TTL from the text", false);
const out = h("div");
const run = async (dry) => {
mount(out);
try {
const body = { text: imp.value, mode: mode.value, use_soa: useSOA.input.checked, dry_run: dry };
const r = dry ? await get_dry(zone, body) : await change("POST", "/zones/" + zone.id + "/import", body);
if (dry) {
mount(out, h("h3", null, "Preview"), diffViewOf(r));
} else {
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
rerender();
}
} catch (e) { mount(out, errorBox(e)); }
};
mount(el,
h("div", { class: "panel" }, h("h2", null, "Generated zone file"), h("p", { class: "muted small" }, state.draft && !state.immediate ? "What Jarl will write for named once the pending changes are applied." : "This is exactly what Jarl writes for named."), h("pre", null, text)),
h("div", { class: "panel" }, h("h2", null, "Import records from zone file text"),
h("p", { class: "muted small" }, "Paste records in zone file format. Names are relative to " + zone.display_name + ". The SOA record is ignored unless you tick the box."),
imp, h("div", { class: "actions", style: { marginTop: "8px" } }, mode, useSOA,
h("button", { onclick: () => run(true) }, "Preview"),
h("button", { class: "primary", onclick: () => run(false) }, "Import")),
out));
}
async function get_dry(zone, body) {
return api("POST", "/zones/" + zone.id + "/import", { body: { ...body, dry_run: true } });
}
function diffViewOf(r) {
const res = r && r.result;
if (!res) return h("div", { class: "muted" }, (r.warnings || []).join("; ") || "Nothing to import.");
return h("div", null, (r.warnings || []).length ? h("div", { class: "msg warn" }, r.warnings.join("; ")) : null, diffView(res.diff));
}
// toggleZone disables or enables a zone (asking first when disabling).
async function toggleZone(zone) {
if (!zone.disabled) {
const where = zone.view ? "view " + zone.view : "this server";
const ok = await confirmDialog("Disable zone " + zone.display_name,
"named stops serving " + zone.display_name + " in " + where + ". Its names are then answered by recursion where the view offers it (the internet's version of the zone, if any), and REFUSED elsewhere. The zone, its records and options stay in Jarl.", "Disable zone", "danger");
if (!ok) return;
}
try {
const r = await change("PATCH", "/zones/" + zone.id, { disabled: !zone.disabled });
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
async function fillPTR(zone, otherViews) {
try {
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr", undefined, otherViews ? { query: { other_views: "1" } } : {});
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}