503 lines
29 KiB
JavaScript
503 lines
29 KiB
JavaScript
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, 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" },
|
||
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
|
||
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, 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." });
|
||
}
|
||
|
||
async function serversTab(el, zone) {
|
||
const serials = h("div");
|
||
const loadSerials = async () => {
|
||
mount(serials, h("div", { class: "muted" }, "Asking the name servers…"));
|
||
try {
|
||
const r = await get("/zones/" + zone.id + "/serials");
|
||
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
|
||
mount(serials,
|
||
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)),
|
||
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), "."));
|
||
} catch (err) {
|
||
mount(serials, errorBox(err));
|
||
}
|
||
};
|
||
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"),
|
||
h("button", { class: "small", onclick: loadSerials }, "Check 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 resolver = input({ placeholder: "the server's own" });
|
||
const run = async (e) => {
|
||
e.preventDefault();
|
||
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
|
||
try {
|
||
const q = resolver.value.trim() ? { resolver: resolver.value.trim() } : {};
|
||
mount(out, delegationReport(await get("/zones/" + zone.id + "/delegation", q)));
|
||
} catch (err) {
|
||
mount(out, errorBox(err));
|
||
}
|
||
};
|
||
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);
|
||
await loadSerials();
|
||
}
|
||
|
||
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);
|
||
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 = "";
|
||
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()], "");
|
||
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 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));
|
||
let prevName = null;
|
||
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, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
|
||
{ 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,
|
||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||
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) => r.pending ? "pending-" + r.pending : "" }));
|
||
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); }
|
||
};
|
||
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);
|
||
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,
|
||
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));
|
||
}
|