diff --git a/internal/api/types.go b/internal/api/types.go index 80d7d07..b997e20 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -32,6 +32,8 @@ type Zone struct { // LinkedWith are the views of the zones this one is linked with // (docs/views.md#linked-zones). LinkedWith []string `json:"linked_with,omitempty"` + // Links are the zones this one is linked with, with their views. + Links []ZoneLink `json:"links,omitempty"` // Pending is set when listing with a changeset: add, change or delete. Pending string `json:"pending,omitempty"` } @@ -567,3 +569,9 @@ type BlockRule struct { AutoViews bool `json:"auto_views,omitempty"` Pending string `json:"pending,omitempty"` } + +// ZoneLink is a zone a zone is linked with. +type ZoneLink struct { + ID int64 `json:"id"` + View string `json:"view"` +} diff --git a/internal/server/present.go b/internal/server/present.go index 61153d3..34fbbd3 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -24,6 +24,7 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re out.Registrar = s.registrarFor(z) for _, o := range st.LinkedWith(z) { out.LinkedWith = append(out.LinkedWith, st.ViewName(o)) + out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)}) } return out } diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index 6d5e114..31d189e 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -34,7 +34,8 @@ export async function render(el, params, query) { zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null, zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null, zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null, - zone.linked_with ? [badge("linked with " + zone.linked_with.join(", "), "accent"), " "] : null, + zone.links ? [h("span", { class: "badge accent" }, "linked with ", + zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null, zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)), h("div", { class: "actions" }, zone.disabled @@ -94,25 +95,53 @@ async function linkDialog(zone) { await modal("Link zone", h("p", null, "No other view has a zone " + zone.display_name + ". Create it there linked: New zone, the same name, \"Copy records from\" this one with \"Keep them linked\" ticked.")); return; } - const boxes = others.map((z) => { const c = checkbox("view " + z.view, true); c.view = z.view; return c; }); + const picked = new Set(); const err = h("div"); - await modal("Link " + zone.display_name, h("div", null, + let linkBtn = null; + const rows = others.map((z) => { + const box = h("input", { type: "checkbox", "aria-label": "Link with view " + z.view }); + box.addEventListener("change", () => { + if (box.checked) picked.add(z.view); else picked.delete(z.view); + tr.classList.toggle("selected", box.checked); + if (linkBtn) { + linkBtn.disabled = picked.size === 0; + // Zones already linked with a picked one join too: name them. + const all = new Set(picked); + for (const o of others) if (picked.has(o.view)) (o.linked_with || []).forEach((v) => all.add(v)); + all.delete(zone.view); + linkBtn.textContent = picked.size ? "Link with " + [...all].join(", ") : "Link"; + } + }); + const tr = h("tr", { class: "pick", onclick: (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } } }, + h("td", null, box), + h("td", null, h("strong", null, z.view)), + h("td", { class: "muted" }, z.record_count + " records"), + h("td", { class: "small" }, z.linked_with ? "already linked with " + z.linked_with.join(", ") + " (joins too)" : "")); + return tr; + }); + const body = h("div", null, h("p", null, "Linked zones keep their shared records in step: a change in one view reaches the others in the same change. Each view can still have records of its own, which override the shared ones of that name and type there, and keeps its own options."), h("p", { class: "muted small" }, "Linking changes no answer: records that are the same in every view become shared; anything that differs stays local to each view. Share one later from the view whose version is right."), - field("Link view " + zone.view + " with", h("div", null, boxes)), err), [ + h("h3", null, "Link view " + zone.view + " with"), + h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, rows))), + err); + const open = modal("Link " + zone.display_name, body, [ { label: "Cancel", value: null }, { label: "Link", kind: "primary", onclick: async () => { - const w = boxes.filter((b) => b.input.checked).map((b) => b.view); - if (!w.length) { mount(err, h("div", { class: "msg err" }, "Pick at least one view.")); return false; } + if (!picked.size) return false; try { - const r = await change("POST", "/zones/" + zone.id + "/link", { with: w }); + const r = await change("POST", "/zones/" + zone.id + "/link", { with: [...picked] }); toast(describeResult(r)); (r.warnings || []).forEach((m) => toast(m, "warn", 12000)); rerender(); return true; } catch (e) { mount(err, errorBox(e)); return false; } } }, - ]); + ], { wide: true }); + // Nothing is ticked at first: Link waits for a choice. + linkBtn = [...document.querySelectorAll(".modal .buttons button.primary")].pop(); + if (linkBtn) linkBtn.disabled = true; + await open; } // ---- name servers: serials on the other servers, and the delegation diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 791060d..9d29bde 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -513,3 +513,8 @@ td textarea.code { min-width: min(72ch, 50vw); } /* Certificates: the "check the names" result under the ACME fields. */ .check-names { margin: 6px 0 14px; } .check-names .table-wrap { margin-top: 8px; } + +/* A list to pick rows from (linking zones): the whole row toggles. */ +table.pick-list tr.pick { cursor: pointer; } +table.pick-list tr.pick td:first-child { width: 2em; } +table.pick-list tr.pick.selected { background: var(--accent-soft); }