Link views from the zone list, add breadcrumbs and view colours
The view shown in the zone list and on the zone page now links to that
view. The zone page and changeset page get breadcrumbs, and the zone list
keeps its filters in the address so going back restores them. Views can
be given a colour (Views > Edit, `jarl view color`, PUT /views/{id}/color)
so zones of the same view are easy to spot; the colour is UI-only, kept
outside changesets, and needs no deploy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b22ffee1bb
commit
b05711b13e
20 files changed
+393
-30
No files matched your search
@@ -1,5 +1,5 @@
|
||||
// Jarl web UI: router, layout and login.
|
||||
import { h, mount, toast, errorBox, plural } from "./ui.js";
|
||||
import { h, mount, toast, errorBox, plural, lastList } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate } from "./api.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
@@ -151,6 +151,7 @@ async function route() {
|
||||
const params = {};
|
||||
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
|
||||
current = { key: path, nav: r.nav, page: r.page, params, query };
|
||||
if (!r.params && r.nav) lastList[r.nav] = location.hash;
|
||||
renderNav();
|
||||
mount(mainEl, h("div", { class: "muted" }, "Loading…"));
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, toast, confirmDialog, diffView, time, fmtTime, badge, plural } from "../ui.js";
|
||||
import { h, mount, table, toast, confirmDialog, diffView, time, fmtTime, badge, plural, crumbs, lastList } from "../ui.js";
|
||||
import { api, get, useDraft } from "../api.js";
|
||||
|
||||
export async function render(el, params) {
|
||||
@@ -30,10 +30,10 @@ async function detail(el, id) {
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" },
|
||||
crumbs(["History", lastList.history || "#/history"]),
|
||||
h("h1", null, "Changeset #" + c.id),
|
||||
h("div", { class: "muted" }, c.description || "(no description)")),
|
||||
h("div", { class: "actions" },
|
||||
h("a", { class: "button", href: "#/history" }, "All changes"),
|
||||
c.status === "applied" && c.undoable ? h("button", { onclick: () => revert(c) }, "Revert…") : null)),
|
||||
h("div", { class: "panel" }, h("dl", { class: "kv" },
|
||||
h("dt", null, "Status"), h("dd", null, badge(c.status, c.status === "applied" ? "ok" : "")),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js";
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, colorPicker } from "../ui.js";
|
||||
import { amlContext, suggestions } from "../aml.js";
|
||||
import { get, change, describeResult } from "../api.js";
|
||||
import { api, get, change, describeResult } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { optionsEditor } from "./options.js";
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function render(el, params, query) {
|
||||
"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 }, h("strong", null, v.name)) },
|
||||
{ label: "View", render: (v) => h("a", { href: "#/views?view=" + 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)) },
|
||||
@@ -37,10 +37,14 @@ export async function render(el, params, query) {
|
||||
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." })),
|
||||
], 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)." });
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
async function viewDialog(v, viewless) {
|
||||
@@ -51,13 +55,15 @@ async function viewDialog(v, viewless) {
|
||||
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("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, [
|
||||
@@ -66,9 +72,16 @@ async function viewDialog(v, viewless) {
|
||||
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 {
|
||||
const r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data);
|
||||
toast(describeResult(r));
|
||||
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; }
|
||||
@@ -76,6 +89,15 @@ async function viewDialog(v, viewless) {
|
||||
], { 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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView } from "../ui.js";
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, crumbs, 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";
|
||||
@@ -7,7 +7,8 @@ import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const id = params.id;
|
||||
const zone = await get("/zones/" + id, draftQuery());
|
||||
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,
|
||||
@@ -20,11 +21,13 @@ export async function render(el, params, query) {
|
||||
mount(el,
|
||||
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)]] : [])),
|
||||
h("h1", { class: "mono" }, zone.display_name),
|
||||
h("div", { class: "muted" },
|
||||
badge(zone.type, "accent"), " ",
|
||||
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
||||
zone.view ? ["view ", h("a", { href: "#/views" }, zone.view), " · "] : null,
|
||||
zone.view ? ["view ", viewLink(view, zone.view), " · "] : 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" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList } from "../ui.js";
|
||||
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { checkDomain, reverseZoneFor } from "../names.js";
|
||||
@@ -8,13 +8,23 @@ export async function render(el, params, query) {
|
||||
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
|
||||
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
|
||||
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
|
||||
const viewsById = new Map(views.map((v) => [v.id, v]));
|
||||
const list = h("div");
|
||||
const draw = () => {
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
// Keep the filters in the address, so going back to the list (or a
|
||||
// breadcrumb) restores them.
|
||||
const qs = new URLSearchParams();
|
||||
if (filter.value.trim()) qs.set("q", filter.value.trim());
|
||||
if (viewSel.value) qs.set("view", viewSel.value);
|
||||
if (typeSel.value) qs.set("type", typeSel.value);
|
||||
const hash = "#/zones" + (qs.toString() ? "?" + qs : "");
|
||||
if (location.hash !== hash && !query.get("new")) history.replaceState(null, "", hash);
|
||||
lastList.zones = hash;
|
||||
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) => z.view || h("span", { class: "muted" }, "–") } : null,
|
||||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view) } : 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" },
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
--hunk: #e8eefb;
|
||||
--shadow: 0 1px 2px rgba(20, 30, 50, 0.08);
|
||||
--radius: 6px;
|
||||
--vc-green: #3d8160; --vc-teal: #2f7c80; --vc-purple: #7a5ba8; --vc-pink: #a8557f;
|
||||
--vc-orange: #a8652f; --vc-yellow: #8a7424; --vc-red: #b0534c; --vc-grey: #6b7280;
|
||||
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
color-scheme: light;
|
||||
}
|
||||
@@ -50,6 +52,8 @@
|
||||
--del-text: #f7b2ac;
|
||||
--hunk: #23314a;
|
||||
--shadow: none;
|
||||
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +63,8 @@
|
||||
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
|
||||
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
|
||||
--shadow: none; color-scheme: dark;
|
||||
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -382,3 +388,18 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
|
||||
header.top .brand .logo { transition: none; }
|
||||
header.top .brand:hover .logo { transform: none; }
|
||||
}
|
||||
|
||||
/* breadcrumbs above detail pages */
|
||||
.crumbs { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: 13px; color: var(--muted); margin-bottom: 2px; }
|
||||
.crumbs .sep { opacity: 0.6; }
|
||||
|
||||
/* 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-pink { color: var(--vc-pink); } .vc-orange { color: var(--vc-orange); } .vc-yellow { color: var(--vc-yellow); }
|
||||
.vc-red { color: var(--vc-red); } .vc-grey { color: var(--vc-grey); } .vc-default { color: var(--accent); }
|
||||
.swatches { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 0; }
|
||||
.swatch { width: 24px; height: 24px; min-width: 0; padding: 0; border-radius: 50%; background: currentColor; border: 2px solid var(--panel); box-shadow: 0 0 0 1px var(--border); cursor: pointer; }
|
||||
.swatch:hover { background: currentColor; }
|
||||
.swatch.selected { box-shadow: 0 0 0 2px currentColor; }
|
||||
.swatch:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
||||
tr.selected td { background: var(--accent-soft); }
|
||||
@@ -247,3 +247,43 @@ export function debounce(fn, ms = 250) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
// Where each list page was last left (with its filters), so a breadcrumb
|
||||
// back to it restores them. The router and list pages fill it in.
|
||||
export const lastList = {};
|
||||
|
||||
// crumbs is a breadcrumb trail above a detail page's heading: the pages
|
||||
// it belongs to, as [label, href] pairs (the heading names the page).
|
||||
export function crumbs(...items) {
|
||||
const out = [];
|
||||
items.forEach(([label, href], i) => {
|
||||
if (i > 0) out.push(h("span", { class: "sep", "aria-hidden": "true" }, "›"));
|
||||
out.push(h("a", { href }, label));
|
||||
});
|
||||
return h("nav", { class: "crumbs", "aria-label": "Breadcrumb" }, out);
|
||||
}
|
||||
|
||||
// The colours a view can be shown in (besides the default, the link
|
||||
// colour). Keep in step with model.ViewColors.
|
||||
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
|
||||
// from /views; name is shown when the view is unknown.
|
||||
export function viewLink(v, name) {
|
||||
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);
|
||||
}
|
||||
|
||||
// colorPicker picks a view colour; its value is "" for the default.
|
||||
export function colorPicker(value) {
|
||||
const el = h("div", { class: "swatches", role: "radiogroup" });
|
||||
const draw = () => mount(el, ["", ...viewColors].map((c) => h("button", {
|
||||
type: "button", role: "radio", "aria-checked": String(c === el.value),
|
||||
class: "swatch" + (c ? " vc-" + c : " vc-default") + (c === el.value ? " selected" : ""),
|
||||
title: c || "default (link colour)", "aria-label": c || "default",
|
||||
onclick: () => { el.value = c; draw(); },
|
||||
})));
|
||||
el.value = value || "";
|
||||
draw();
|
||||
return el;
|
||||
}
|
||||
Reference in new issue
Block a user