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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 18:12:56 +01:00
1 parent 7be0762f48
commit c696d02513
6 files changed
+139 -49

No files matched your search

+108 -44
View File
@@ -1,17 +1,19 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker, backLink, safeBack, viewHref } from "../ui.js";
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker, backLink, safeBack, viewHref, badge } from "../ui.js";
import { amlContext, suggestions } from "../aml.js";
import { api, get, change, describeResult } from "../api.js";
import { newZoneDialog, dnssecLock, pendingBadge } from "./zones.js";
import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
export async function render(el, params, query) {
// Old links (#/views?view=ID) open the view's page.
if (query.get("view")) {
history.replaceState(null, "", viewHref(query.get("view"), query.get("back") || ""));
return rerender();
}
if (params.id) return viewPage(el, params, query);
const [views, zones] = await Promise.all([get("/views"), get("/zones")]);
const viewless = zones.filter((z) => !z.view_id);
const optsEl = h("div");
const selected = query.get("view") ? views.find((v) => String(v.id) === query.get("view")) : null;
// Opened from a view link on a zone or the zone list: offer the way back
// (kept while picking other views here).
const back = safeBack(query.get("back"));
const move = async (i, d) => {
const ids = views.map((v) => v.id);
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
@@ -23,31 +25,83 @@ export async function render(el, params, query) {
};
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, back ? backLink(backLabel(back, zones), back) : null, h("h1", null, "Views"),
h("div", { class: "grow" }, h("h1", null, "Views"),
h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")),
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(null, viewless) }, "New view"))),
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(viewless) }, "New view"))),
views.length === 0 ? h("div", { class: "msg info" },
"No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null,
h("div", { class: "panel" }, table([
{ label: "#", render: (v, i) => String(i + 1) },
{ label: "View", render: (v) => h("a", { href: viewHref(v.id, back), class: v.color ? "vc-" + v.color : null }, h("strong", null, v.name)) },
{ label: "View", render: (v) => h("a", { href: viewHref(v.id), class: v.color ? "vc-" + v.color : null }, h("strong", null, v.name)) },
{ label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
{ label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" },
{ label: "Zones", render: (v) => h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, String(v.zone_count)) },
{ label: "Zones", render: (v) => h("a", { href: viewHref(v.id) + "/zones" }, String(v.zone_count)) },
{ label: "Comment", render: (v) => h("span", { class: "muted" }, v.comment || "") },
{ label: "", render: (v, i) => h("span", null,
h("button", { class: "small", disabled: i === 0, title: "Move up", onclick: () => move(i, -1) }, "↑"), " ",
h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓"), " ",
h("button", { class: "small", onclick: () => viewDialog(v, viewless) }, "Edit"), " ",
h("button", { class: "small danger", onclick: () => deleteView(v) }, "Delete")), tdClass: "actions-cell" },
], views, { empty: "No views.", rowClass: (v) => selected && v.id === selected.id ? "selected" : "" })),
selected ? optsEl : (views.length ? h("p", { class: "muted" }, "Select a view to see its options.") : null));
if (selected) {
await optionsEditor(optsEl, { scope: "view", scope_id: selected.id, catalogScope: "view", title: "Options of view " + selected.name,
intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." });
// With a long list, scroll to the selected view's options.
if (views.length > 3) optsEl.scrollIntoView({ block: "start" });
{ label: "Order", render: (v, i) => h("span", null,
h("button", { class: "small", disabled: i === 0, title: "Move up: clients are matched against it earlier", onclick: () => move(i, -1) }, "↑"), " ",
h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓")), tdClass: "actions-cell" },
], views, { empty: "No views." })));
}
// viewPage is one view, laid out like a zone: its settings, its options
// and its zones, each on a tab.
async function viewPage(el, params, query) {
const id = Number(params.id);
const [views, zones] = await Promise.all([get("/views"), get("/zones")]);
const i = views.findIndex((v) => v.id === id);
const v = views[i];
const back = safeBack(query.get("back"));
if (!v) {
mount(el, backLink("Views", "#/views"), h("div", { class: "msg err" }, "View " + id + " doesn't exist (any more)."));
return;
}
const inView = zones.filter((z) => z.view_id === id);
const tab = params.tab || "settings";
const base = viewHref(id);
const suffix = back ? "?back=" + encodeURIComponent(back) : "";
const tabs = [["settings", "Settings"], ["options", "Options"], ["zones", "Zones (" + inView.length + ")"]];
const head = h("div", { class: "page-head" },
h("div", { class: "grow" },
back ? backLink(backLabel(back, zones), back) : backLink("Views", "#/views"),
h("h1", { class: v.color ? "vc-" + v.color : null }, v.name),
h("div", { class: "muted" },
"view " + (i + 1) + " of " + views.length + " · clients: " + ((v.match_clients || []).join("; ") || "any") +
((v.match_destinations || []).length ? " · destinations: " + v.match_destinations.join("; ") : "") +
(v.comment ? " · " + v.comment : ""))),
h("div", { class: "actions" }, h("button", { class: "danger", onclick: () => deleteView(v) }, "Delete view")));
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: base + "/" + k + suffix, class: k === tab ? "active" : "" }, label)));
const body = h("div");
mount(el, head, tabBar, body);
if (tab === "options") {
await optionsEditor(body, { scope: "view", scope_id: id, catalogScope: "view", title: "View options",
intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." });
} else if (tab === "zones") {
await zonesTab(body, v, views, zones, inView);
} else {
await settingsTab(body, v);
}
const first = body.querySelector("[data-autofocus]");
if (first) first.focus();
}
async function settingsTab(el, v) {
const f = await viewForm(v, []);
mount(el, h("div", { class: "panel" }, f.body,
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: async () => { if (await f.save()) rerender(); } }, "Save settings"))));
}
async function zonesTab(el, v, views, zones, inView) {
mount(el, h("div", { class: "panel" },
h("div", { class: "actions", style: { marginBottom: "10px" } },
h("button", { class: "primary", onclick: async () => newZoneDialog(views, zones, await get("/settings"), { view: v.name }) }, "New zone in " + v.name),
h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, "Show on the Zones page")),
table([
{ label: "Zone", render: (z) => [h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
{ label: "Records", render: (z) => (z.type === "primary" ? String(z.record_count) : ""), class: "right", tdClass: "right" },
{ label: "Serial", render: (z) => (z.type === "primary" ? String(z.soa.serial) : ""), tdClass: "mono" },
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
], inView, { empty: "No zones in this view yet.", rowClass: (z) => (z.disabled ? "disabled" : "") })));
}
// backLabel names the page a back address leads to.
@@ -60,10 +114,12 @@ function backLabel(back, zones) {
return /^#\/zones(\?|$)/.test(back) ? "Zones" : "Back";
}
async function viewDialog(v, viewless) {
// viewForm is the form for a view's settings (v null: a new view); save
// sends it and reports whether it worked.
async function viewForm(v, viewless) {
const err = h("div");
const ctx = await amlContext();
const name = input({ value: v ? v.name : "", placeholder: "internal" });
const name = input({ value: v ? v.name : "", placeholder: "internal", "data-autofocus": v ? null : true });
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
@@ -79,26 +135,32 @@ async function viewDialog(v, viewless) {
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
err);
await modal(v ? "Edit view " + v.name : "New view", body, [
const save = async () => {
mount(err);
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
const colorChanged = color.value !== ((v && v.color) || "");
try {
let r = null;
if (!v || configChanged(v, data)) r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data);
const id = v ? v.id : (r.object && r.object.id) || ((r.added || [])[0] || {}).id;
if (colorChanged && id) {
// A view created in a draft only exists in that draft so far.
await api("PUT", "/views/" + id + "/color", { body: { color: color.value }, stage: r && r.staged ? r.changeset_id : undefined });
}
toast(r ? describeResult(r) : colorChanged ? "Colour saved" : "Nothing changed");
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
};
return { body, save };
}
async function viewDialog(viewless) {
const f = await viewForm(null, viewless);
await modal("New view", f.body, [
{ label: "Cancel", value: null },
{ label: v ? "Save" : "Create view", kind: "primary", onclick: async () => {
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
const colorChanged = color.value !== ((v && v.color) || "");
try {
let r = null;
if (!v || configChanged(v, data)) r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data);
const id = v ? v.id : (r.object && r.object.id) || ((r.added || [])[0] || {}).id;
if (colorChanged && id) {
// A view created in a draft only exists in that draft so far.
await api("PUT", "/views/" + id + "/color", { body: { color: color.value }, stage: r && r.staged ? r.changeset_id : undefined });
}
toast(r ? describeResult(r) : "Colour saved");
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
{ label: "Create view", kind: "primary", onclick: async () => { const ok = await f.save(); if (ok) rerender(); return ok; } },
], { wide: true, sticky: true });
}
@@ -122,6 +184,8 @@ async function deleteView(v) {
try {
const r = await change("DELETE", "/views/" + v.id, undefined, { query: cascade ? { cascade: 1 } : undefined });
toast(describeResult(r));
rerender();
// Applied at once, the view is gone; staged, it is still there.
if (r.applied) location.hash = "#/views";
else rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
+17
View File
@@ -402,6 +402,20 @@ async function recordsTab(el, zone, query) {
}
};
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);
@@ -419,6 +433,7 @@ async function recordsTab(el, zone, query) {
// 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 = "";
@@ -466,6 +481,8 @@ async function recordsTab(el, zone, query) {
{ 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")
+4 -3
View File
@@ -49,7 +49,8 @@ export async function render(el, params, query) {
}
}
export async function newZoneDialog(views, zones, settings) {
// preset.view picks the view (opened from a view's page).
export async function newZoneDialog(views, zones, settings, preset = {}) {
const err = h("div");
// The zone's name matters most and is easy to get wrong, so the dialog
// asks for a domain or a network and shows the resulting zone name.
@@ -60,7 +61,7 @@ export async function newZoneDialog(views, zones, settings) {
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], views.length === 1 ? views[0].name : "");
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], preset.view || (views.length === 1 ? views[0].name : ""));
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
const nsAddr = textarea("", { class: "mono", rows: 2, placeholder: "ns1 192.0.2.1" });
const contact = input({ value: settings.rname ? emailOf(settings.rname) : "", placeholder: "hostmaster@example.com" });
@@ -239,7 +240,7 @@ export function emailOf(rname) {
// dnssecLock marks a signed zone with a padlock linking to its DNSSEC tab:
// closed while signed, open while signing is being turned off (BIND's
// insecure policy).
function dnssecLock(z) {
export function dnssecLock(z) {
if (!z.dnssec_policy) return null;
const off = z.dnssec_policy === "insecure";
const a = h("a", {