diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e1c41..07f41cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to Jarl are documented here. The format follows ### Added +- A zone's page links to the same zone in the other views ("also in + external"), in each view's colour, keeping the tab you are on. It + works for zones that aren't linked too, and the zone API's new + `in_views` lists them. - `jarl.service` has the alias `jarld.service`, so `systemctl restart jarld` works too. The alias is linked when the unit is enabled; the package relinks a unit enabled by an older version on upgrade. diff --git a/docs/api.md b/docs/api.md index 6552a1a..bd6b3a7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -363,7 +363,10 @@ In a linked zone ([views.md](views.md#linked-zones)), records carry (`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local": true` (this view only, an override) or `false` (shared; on an update or a set it makes this view's version every view's). Zones carry `link_group` -and `linked_with` (the other views). +and `linked_with` (the other views). Every zone in a view carries +`in_views`: the zones of the same name in the other views, linked or not +and of any type, in view order (`[{"id": 289, "view": "external", "type": +"primary", "linked": true}]`). | Method | Path | Body | |--------|------|------| diff --git a/docs/views.md b/docs/views.md index 90ce466..3c01931 100644 --- a/docs/views.md +++ b/docs/views.md @@ -168,6 +168,10 @@ shows one (it needs `tsig:write` and is audited). Key names starting with `jarl-view-` are reserved, and the view keys can be used in your own options (`allow-transfer { key jarl-view-external; }`). +**Moving between views.** A zone's page lists the other views that have a +zone of the same name ("also in external", in each view's colour). Each +one links straight to that zone, on the tab you are on. + ## Linked zones Most split-horizon zones are the same in every view apart from a few diff --git a/internal/api/types.go b/internal/api/types.go index 5962386..b1d31fa 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -34,6 +34,9 @@ type Zone struct { LinkedWith []string `json:"linked_with,omitempty"` // Links are the zones this one is linked with, with their views. Links []ZoneLink `json:"links,omitempty"` + // InViews are the zones of this name in the other views (any type), + // in view order: for going straight to one. + InViews []ZoneLink `json:"in_views,omitempty"` // OtherViews are the other views with a primary zone of this name // (linked or not): what GET /zones/{zone}/compare compares. OtherViews []string `json:"other_views,omitempty"` @@ -628,6 +631,9 @@ type BlockRule struct { type ZoneLink struct { ID int64 `json:"id"` View string `json:"view"` + // Type and Linked are set in Zone.InViews. + Type string `json:"type,omitempty"` + Linked bool `json:"linked,omitempty"` } // EncDNSSummary is what the dashboard says about encrypted DNS. diff --git a/internal/server/dsat_test.go b/internal/server/dsat_test.go index bfb824c..5366580 100644 --- a/internal/server/dsat_test.go +++ b/internal/server/dsat_test.go @@ -35,3 +35,17 @@ func TestDNSSECWhereDSGoes(t *testing.T) { } } } + +// A zone lists the zones of its name in the other views. +func TestZoneInViews(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201) + e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201) + e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."}}, 201) + e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "external", "nameservers": []string{"ns1.example.net."}}, 201) + z := e.do("GET", "/zones/adbraun.lol?view=internal", e.token, nil, 200) + in, _ := z["in_views"].([]any) + if len(in) != 1 || in[0].(map[string]any)["view"] != "external" || in[0].(map[string]any)["type"] != "primary" { + t.Errorf("in_views = %v", z["in_views"]) + } +} diff --git a/internal/server/present.go b/internal/server/present.go index 976c7a2..81d431c 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -29,6 +29,15 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re } } } + if z.ViewID != 0 { + others := st.ZonesNamed(z.Name) + st.SortByView(others) + for _, o := range others { + if o.ID != z.ID { + out.InViews = append(out.InViews, api.ZoneLink{ID: o.ID, View: st.ViewName(o), Type: o.Type, Linked: z.LinkGroup != 0 && o.LinkGroup == z.LinkGroup}) + } + } + } if pf, ok := dnsname.PrefixFromReverse(z.Name); ok { out.Prefix = pf.String() if z.Type == model.ZonePrimary { diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index 1ea8f33..1e3e3db 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -9,6 +9,15 @@ import { registrarTab } from "./registrar.js"; let renderSeq = 0; +// tabIn is the "/tab" part of the address of zone o (an in_views entry) +// that keeps the tab open now, when o has that tab. +function tabIn(o, tab) { + if (!tab || tab === "registrar") return ""; + if (["records", "file", "dnssec", "compare"].includes(tab) && o.type !== "primary") return ""; + if (tab === "servers" && !["primary", "secondary", "mirror"].includes(o.type)) return ""; + return "/" + tab; +} + export async function render(el, params, query) { const id = params.id; const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]); @@ -33,12 +42,19 @@ export async function render(el, params, query) { badge(zone.type, "accent"), " ", zone.pending ? [pendingBadge(zone.pending), " "] : null, zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null, + zone.in_views ? [h("span", { title: "The zone " + zone.display_name + " in another view" }, "also in ", + zone.in_views.map((o, i) => { + const v = views.find((x) => x.name === o.view); + return [i ? ", " : "", h("a", { href: "#/zones/" + o.id + tabIn(o, params.tab), class: v && v.color ? "vc-" + v.color : null, + title: "Go to " + zone.display_name + " in view " + o.view + (o.type !== zone.type ? " (" + o.type + ")" : "") + (o.linked ? ", linked with this one" : "") }, o.view), + o.type !== zone.type ? h("span", { class: "muted" }, " (" + o.type + ")") : null]; + })), " · "] : null, zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null, zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null, zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "ok")), " "] : 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.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.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + tabIn({ type: zone.type }, 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