The zone page's breadcrumb (Zones › view) linked the view to a filtered zone list while the view link below it opened the view. It is now a single "← Zones" link back to the list with its filters, and a changeset has "← History". View links carry where they came from, so the views page offers "← example.org (external)" or "← Zones" (only the UI's own #/ addresses are accepted). Switching a zone's tabs no longer blanks the page: the header and tabs show at once and the previous tab stays, faded, until the next has loaded. The router shows "Loading…" only for pages that take a while. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
8.3 KiB
JavaScript
128 lines
8.3 KiB
JavaScript
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker, backLink, safeBack, viewHref } from "../ui.js";
|
|
import { amlContext, suggestions } from "../aml.js";
|
|
import { api, get, change, describeResult } from "../api.js";
|
|
import { rerender } from "../app.js";
|
|
import { optionsEditor } from "./options.js";
|
|
|
|
export async function render(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]];
|
|
try {
|
|
const r = await change("POST", "/views/order", { ids });
|
|
toast(describeResult(r));
|
|
rerender();
|
|
} catch (e) { toast(e.message, "err", 8000); }
|
|
};
|
|
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: "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"))),
|
|
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: "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: "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" });
|
|
}
|
|
}
|
|
|
|
// backLabel names the page a back address leads to.
|
|
function backLabel(back, zones) {
|
|
const m = back.match(/^#\/zones\/(\d+)/);
|
|
if (m) {
|
|
const z = zones.find((z) => String(z.id) === m[1]);
|
|
return z ? z.display_name + (z.view ? " (" + z.view + ")" : "") : "Zone";
|
|
}
|
|
return /^#\/zones(\?|$)/.test(back) ? "Zones" : "Back";
|
|
}
|
|
|
|
async function viewDialog(v, viewless) {
|
|
const err = h("div");
|
|
const ctx = await amlContext();
|
|
const name = input({ value: v ? v.name : "", placeholder: "internal" });
|
|
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) : "");
|
|
const comment = input({ value: v ? v.comment : "" });
|
|
const color = colorPicker(v ? v.color : "");
|
|
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
|
const body = h("div", null, h("div", { class: "form-grid" },
|
|
field("Name", name),
|
|
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
|
|
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
|
|
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
|
field("Comment", comment),
|
|
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, [
|
|
{ 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; }
|
|
} },
|
|
], { wide: true, sticky: true });
|
|
}
|
|
|
|
// configChanged tells whether the dialog changed the view's configuration
|
|
// (not just its colour), so a colour change alone stages nothing.
|
|
function configChanged(v, data) {
|
|
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
|
|
const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only;
|
|
return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) ||
|
|
data.match_recursive_only !== mro || data.comment !== (v.comment || "");
|
|
}
|
|
|
|
async function deleteView(v) {
|
|
let cascade = false;
|
|
if (v.zone_count > 0) {
|
|
const c = checkbox("Also delete its " + v.zone_count + " zone(s) and their records", false);
|
|
const ok = await modal("Delete view " + v.name, h("div", null, h("p", null, "The view contains zones."), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
|
|
if (ok !== true) return;
|
|
cascade = c.input.checked;
|
|
} else if (!(await confirmDialog("Delete view", "Delete view " + v.name + "?", "Delete", "danger"))) return;
|
|
try {
|
|
const r = await change("DELETE", "/views/" + v.id, undefined, { query: cascade ? { cascade: 1 } : undefined });
|
|
toast(describeResult(r));
|
|
rerender();
|
|
} catch (e) { toast(e.message, "err", 8000); }
|
|
}
|