Files
jarl/internal/web/static/assets/pages/zone.js
T
adamandClaude Opus 5.5 c696d02513 Give each view its own page, and add a CNAME from an A record
Views open a page like a zone's, with Settings, Options and Zones tabs
(and New zone in that view); the Views page lists and orders them. A
and AAAA records get a + CNAME button that fills in the add form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:12:56 +01:00

695 lines
39 KiB
JavaScript
Raw 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 } from "../ui.js";
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery } 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;
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.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.type === "primary" ? "serial " + zone.soa.serial + " · " : 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.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"),
" ",
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.") : 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, 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);
// 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();
}
// ---- 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 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", !(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;
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),
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" },
{ 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,
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
? [h("button", { class: "small", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : 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" },
], 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) {
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 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);
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;
if (Object.keys(body).length === 0 || (Object.keys(body).length === 1 && body.ptr)) { 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") ? 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
async function settingsTab(el, zone) {
const views = await get("/views");
const err = h("div");
const rows = [];
const s = zone.soa || {};
const mname = input({ value: display(s.mname), 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 });
if (zone.type === "primary") {
rows.push(
field("Primary name server", mname, "SOA MNAME."),
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 (["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 = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
body.default_ttl = +ttl.value;
body.serial_policy = policy.value;
}
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) { 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); }
}