Replace breadcrumbs with back links; keep the zone header on tab switches
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>
This commit is contained in:
1 parent
db9ebb14dd
commit
4b79abf296
8 files changed
+86
-40
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, toast, confirmDialog, diffView, time, fmtTime, badge, plural, crumbs, lastList } from "../ui.js";
|
||||
import { h, mount, table, toast, confirmDialog, diffView, time, fmtTime, badge, plural, backLink, lastList } from "../ui.js";
|
||||
import { api, get, useDraft } from "../api.js";
|
||||
|
||||
export async function render(el, params) {
|
||||
@@ -30,7 +30,7 @@ async function detail(el, id) {
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" },
|
||||
crumbs(["History", lastList.history || "#/history"]),
|
||||
backLink("History", lastList.history || "#/history"),
|
||||
h("h1", null, "Changeset #" + c.id),
|
||||
h("div", { class: "muted" }, c.description || "(no description)")),
|
||||
h("div", { class: "actions" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker } from "../ui.js";
|
||||
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";
|
||||
@@ -9,6 +9,9 @@ export async function render(el, params, query) {
|
||||
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]];
|
||||
@@ -20,14 +23,14 @@ export async function render(el, params, query) {
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Views"),
|
||||
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: "#/views?view=" + v.id, class: v.color ? "vc-" + v.color : null }, h("strong", null, v.name)) },
|
||||
{ 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)) },
|
||||
@@ -47,6 +50,16 @@ export async function render(el, params, query) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, crumbs, viewLink, lastList } from "../ui.js";
|
||||
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";
|
||||
|
||||
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")]);
|
||||
@@ -18,27 +20,39 @@ export async function render(el, params, query) {
|
||||
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
|
||||
].filter(Boolean);
|
||||
const body = h("div");
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
const head = h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" },
|
||||
crumbs(["Zones", lastList.zones || "#/zones"],
|
||||
...(zone.view ? [[h("span", { class: view && view.color ? "vc-" + view.color : null }, zone.view), "#/zones?view=" + encodeURIComponent(zone.view)]] : [])),
|
||||
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), " · "] : null,
|
||||
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
|
||||
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : 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"))),
|
||||
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))),
|
||||
body);
|
||||
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);
|
||||
// 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
|
||||
@@ -162,7 +176,7 @@ async function recordsTab(el, zone, query) {
|
||||
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" });
|
||||
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 });
|
||||
@@ -384,7 +398,6 @@ async function recordsTab(el, zone, query) {
|
||||
(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();
|
||||
fName.focus();
|
||||
}
|
||||
|
||||
// ---- settings
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function render(el, params, query) {
|
||||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||||
mount(list, table([
|
||||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view) } : null,
|
||||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
||||
{ 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" },
|
||||
|
||||
Reference in new issue
Block a user