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:
abrown38andClaude Opus 5.5 committed 2026-10-09 15:07:55 +00:00
1 parent db9ebb14dd
commit 4b79abf296
8 files changed
+86 -40

No files matched your search

+8 -5
View File
@@ -19,10 +19,11 @@ All notable changes to Jarl are documented here. The format follows
list show its name in that colour. The colours are muted, and pastel in list show its name in that colour. The colours are muted, and pastel in
dark mode. The default is the link colour. A colour isn't configuration, dark mode. The default is the link colour. A colour isn't configuration,
so setting one needs no changeset or deploy. so setting one needs no changeset or deploy.
- Breadcrumbs on the zone page (Zones › view) and the changeset page - A "← Zones" link above a zone, back to the zone list, and "← History"
(History). The zone list keeps its filter, view and type in the address, above a changeset. The zone list keeps its filter, view and type in the
so going back to it, by breadcrumb or the browser's back button, restores address, so going back to it (with that link or the browser's back
them. button) restores them. A view opened from a zone or the zone list has a
"← example.com (external)" or "← Zones" link back.
### Changed ### Changed
@@ -33,10 +34,12 @@ All notable changes to Jarl are documented here. The format follows
release still showed the release's version. `jarld version`, release still showed the release's version. `jarld version`,
`jarl --version`, `GET /status` and `/auth/whoami` report the same, the `jarl --version`, `GET /status` and `/auth/whoami` report the same, the
latter two as `build` (docs/development.md). latter two as `build` (docs/development.md).
- The view in the zone list, and on the zone page, links to that view, - The view in the zone list, and on the zone page, links to that view,
and the views page highlights it. It used to be plain text, and the zone and the views page highlights it. It used to be plain text, and the zone
page linked to the views page as a whole. page linked to the views page as a whole.
- Switching a zone's tabs (Records, Settings, …) no longer blanks the page
while the tab loads: the page changes once the tab is ready, and
"Loading…" appears only if a page takes a while.
### Fixed ### Fixed
+11 -3
View File
@@ -49,7 +49,7 @@ const navItems = [
]; ];
const app = document.getElementById("app"); const app = document.getElementById("app");
let mainEl, navEl, barEl, current = { key: "" }; let mainEl, navEl, barEl, current = { key: "" }, routeGen = 0;
function parseHash() { function parseHash() {
const raw = location.hash.replace(/^#\/?/, ""); const raw = location.hash.replace(/^#\/?/, "");
@@ -162,11 +162,19 @@ async function route() {
current = { key: path, nav: r.nav, page: r.page, params, query }; current = { key: path, nav: r.nav, page: r.page, params, query };
if (!r.params && r.nav) lastList[r.nav] = location.hash; if (!r.params && r.nav) lastList[r.nav] = location.hash;
renderNav(); renderNav();
mount(mainEl, h("div", { class: "muted" }, "Loading…")); // Keep the old page until the new one is drawn (switching a zone's
// tabs shouldn't blank the page); say "Loading…" only when it's slow.
// (Only while the new page has drawn nothing yet: pages that draw
// their frame first fill it in afterwards.)
const gen = ++routeGen;
const old = mainEl.firstChild;
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
try { try {
await r.page.render(mainEl, params, query); await r.page.render(mainEl, params, query);
} catch (e) { } catch (e) {
mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e)); if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
} finally {
clearTimeout(slow);
} }
return; return;
} }
+2 -2
View File
@@ -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"; import { api, get, useDraft } from "../api.js";
export async function render(el, params) { export async function render(el, params) {
@@ -30,7 +30,7 @@ async function detail(el, id) {
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "page-head" },
h("div", { class: "grow" }, h("div", { class: "grow" },
crumbs(["History", lastList.history || "#/history"]), backLink("History", lastList.history || "#/history"),
h("h1", null, "Changeset #" + c.id), h("h1", null, "Changeset #" + c.id),
h("div", { class: "muted" }, c.description || "(no description)")), h("div", { class: "muted" }, c.description || "(no description)")),
h("div", { class: "actions" }, h("div", { class: "actions" },
+16 -3
View File
@@ -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 { amlContext, suggestions } from "../aml.js";
import { api, get, change, describeResult } from "../api.js"; import { api, get, change, describeResult } from "../api.js";
import { rerender } from "../app.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 viewless = zones.filter((z) => !z.view_id);
const optsEl = h("div"); const optsEl = h("div");
const selected = query.get("view") ? views.find((v) => String(v.id) === query.get("view")) : null; 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 move = async (i, d) => {
const ids = views.map((v) => v.id); const ids = views.map((v) => v.id);
[ids[i], ids[i + d]] = [ids[i + d], ids[i]]; [ids[i], ids[i + d]] = [ids[i + d], ids[i]];
@@ -20,14 +23,14 @@ export async function render(el, params, query) {
}; };
mount(el, mount(el,
h("div", { class: "page-head" }, 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: "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(null, viewless) }, "New view"))),
views.length === 0 ? h("div", { class: "msg info" }, 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, "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([ h("div", { class: "panel" }, table([
{ label: "#", render: (v, i) => String(i + 1) }, { 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 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: "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: "#/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) { async function viewDialog(v, viewless) {
const err = h("div"); const err = h("div");
const ctx = await amlContext(); const ctx = await amlContext();
+24 -11
View File
@@ -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 { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { optionsEditor } from "./options.js"; import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js"; import { emailOf, pendingBadge } from "./zones.js";
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js"; import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
let renderSeq = 0;
export async function render(el, params, query) { export async function render(el, params, query) {
const id = params.id; const id = params.id;
const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]); 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, ["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
].filter(Boolean); ].filter(Boolean);
const body = h("div"); const body = h("div");
mount(el, const head = h("div", { class: "page-head" },
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("div", { class: "grow" },
crumbs(["Zones", lastList.zones || "#/zones"], backLink("Zones", lastList.zones || "#/zones"),
...(zone.view ? [[h("span", { class: view && view.color ? "vc-" + view.color : null }, zone.view), "#/zones?view=" + encodeURIComponent(zone.view)]] : [])),
h("h1", { class: "mono" }, zone.display_name), h("h1", { class: "mono" }, zone.display_name),
h("div", { class: "muted" }, h("div", { class: "muted" },
badge(zone.type, "accent"), " ", badge(zone.type, "accent"), " ",
zone.pending ? [pendingBadge(zone.pending), " "] : null, 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.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)), zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" }, h("div", { class: "actions" },
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))), 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))), const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
body); // 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); if (tab === "records") await recordsTab(body, zone, query);
else if (tab === "settings") await settingsTab(body, zone); 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 === "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 === "file") await fileTab(body, zone);
else if (tab === "servers") await serversTab(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 // ---- 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 reverse = isReverseZone(zone.name);
const rinfo = reverse ? reverseInfo(zone.name) : null; const rinfo = reverse ? reverseInfo(zone.name) : null;
// Add form. In reverse zones records are named by address. // 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 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 fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
const fData = input({ class: "mono", placeholder: typeInfo.A.example }); 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."))), (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)); list));
draw(); draw();
fName.focus();
} }
// ---- settings // ---- settings
+1 -1
View File
@@ -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)); 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([ 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" }, { 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: "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: "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: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
+5 -3
View File
@@ -388,9 +388,11 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
header.top .brand:hover .logo { transform: none; } header.top .brand:hover .logo { transform: none; }
} }
/* breadcrumbs above detail pages */ /* a zone tab's previous content while the next tab loads */
.crumbs { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: 13px; color: var(--muted); margin-bottom: 2px; } .zone-body.stale { opacity: 0.5; transition: opacity 0.15s 0.1s; pointer-events: none; }
.crumbs .sep { opacity: 0.6; }
/* the "← Zones" link above a page's heading */
.back-link { font-size: 13px; margin-bottom: 2px; }
/* view colours (the default is the link colour) */ /* view colours (the default is the link colour) */
.vc-green { color: var(--vc-green); } .vc-teal { color: var(--vc-teal); } .vc-purple { color: var(--vc-purple); } .vc-green { color: var(--vc-green); } .vc-teal { color: var(--vc-teal); } .vc-purple { color: var(--vc-purple); }
+19 -12
View File
@@ -252,15 +252,16 @@ export function debounce(fn, ms = 250) {
// back to it restores them. The router and list pages fill it in. // back to it restores them. The router and list pages fill it in.
export const lastList = {}; export const lastList = {};
// crumbs is a breadcrumb trail above a detail page's heading: the pages // backLink is the "← Zones" link above a page's heading, back to the
// it belongs to, as [label, href] pairs (the heading names the page). // page it was opened from.
export function crumbs(...items) { export function backLink(label, href) {
const out = []; return h("nav", { class: "back-link", "aria-label": "Back" }, h("a", { href }, "← ", label));
items.forEach(([label, href], i) => { }
if (i > 0) out.push(h("span", { class: "sep", "aria-hidden": "true" }, "›"));
out.push(h("a", { href }, label)); // safeBack returns a "back" address taken from the URL if it is one of
}); // the UI's own (#/...), else null: it ends up in an href.
return h("nav", { class: "crumbs", "aria-label": "Breadcrumb" }, out); export function safeBack(v) {
return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null;
} }
// The colours a view can be shown in (besides the default, the link // The colours a view can be shown in (besides the default, the link
@@ -268,10 +269,16 @@ export function crumbs(...items) {
export const viewColors = ["green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"]; export const viewColors = ["green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"];
// viewLink links to a view's page, in the view's colour. v is a view // viewLink links to a view's page, in the view's colour. v is a view
// from /views; name is shown when the view is unknown. // from /views; name is shown when the view is unknown. back is the
export function viewLink(v, name) { // address the views page offers to go back to (the current page).
export function viewLink(v, name, back) {
if (!v) return name ? h("span", null, name) : h("span", { class: "muted" }, "–"); if (!v) return name ? h("span", null, name) : h("span", { class: "muted" }, "–");
return h("a", { href: "#/views?view=" + v.id, class: v.color ? "vc-" + v.color : null, title: "View " + v.name }, v.name); return h("a", { href: viewHref(v.id, back), class: v.color ? "vc-" + v.color : null, title: "View " + v.name }, v.name);
}
// viewHref is the address of a view on the views page.
export function viewHref(id, back) {
return "#/views?view=" + id + (back ? "&back=" + encodeURIComponent(back) : "");
} }
// colorPicker picks a view colour; its value is "" for the default. // colorPicker picks a view colour; its value is "" for the default.