From b05711b13ed28dc552aded1886d2bc28a2c98782 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Fri, 9 Oct 2026 14:43:57 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 15 ++++ cmd/jarl/complete.go | 3 + cmd/jarl/config_cmds.go | 42 ++++++++++- docs/api.md | 10 +++ docs/cli.md | 1 + docs/views.md | 5 ++ internal/api/types.go | 8 +- internal/model/model.go | 4 + internal/server/h_config.go | 63 ++++++++++++++-- internal/server/present.go | 4 +- internal/server/routes.go | 1 + internal/server/viewcolor_test.go | 81 +++++++++++++++++++++ internal/store/viewcolors.go | 55 ++++++++++++++ internal/web/static/assets/app.js | 3 +- internal/web/static/assets/pages/history.js | 4 +- internal/web/static/assets/pages/views.js | 40 +++++++--- internal/web/static/assets/pages/zone.js | 9 ++- internal/web/static/assets/pages/zones.js | 14 +++- internal/web/static/assets/style.css | 21 ++++++ internal/web/static/assets/ui.js | 40 ++++++++++ 20 files changed, 393 insertions(+), 30 deletions(-) create mode 100644 internal/server/viewcolor_test.go create mode 100644 internal/store/viewcolors.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dd760af..12007ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ All notable changes to Jarl are documented here. The format follows zone (`example.com` or `JARL_TEST_ZONE`) and a reverse zone, starts named and prints the steps to install Jarl and adopt the configuration (docs/development.md). +- View colours. Give a view a colour (Views → Edit, `jarl view color`, or + `PUT /views/{id}/color`) and the zone list, the zone page and the views + 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, + so setting one needs no changeset or deploy. +- Breadcrumbs on the zone page (Zones › view) and the changeset page + (History). The zone list keeps its filter, view and type in the address, + so going back to it, by breadcrumb or the browser's back button, restores + them. + +### Changed + +- 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 + page linked to the views page as a whole. ### Fixed diff --git a/cmd/jarl/complete.go b/cmd/jarl/complete.go index aef86e5..e5e5e05 100644 --- a/cmd/jarl/complete.go +++ b/cmd/jarl/complete.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/cobra" "jarl/internal/api" + "jarl/internal/model" "jarl/internal/rr" ) @@ -71,6 +72,8 @@ func argCompletion(c *cobra.Command) cobra.CompletionFunc { return lookup(cmd, "zone") case p == "VIEW": return lookup(cmd, "view") + case p == "COLOR": + return append([]string{"default"}, model.ViewColors...), cobra.ShellCompDirectiveNoFileComp case p == "ACL": return lookup(cmd, "acl") case p == "USER": diff --git a/cmd/jarl/config_cmds.go b/cmd/jarl/config_cmds.go index cf9bc2c..7e33c55 100644 --- a/cmd/jarl/config_cmds.go +++ b/cmd/jarl/config_cmds.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net/url" + "slices" "strings" "github.com/spf13/cobra" @@ -27,9 +28,9 @@ func viewCmd() *cobra.Command { return err } return output(views, func(w io.Writer) { - fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES") + fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR") for i, v := range views { - fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones) + fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color)) } }) }, @@ -172,10 +173,45 @@ func viewCmd() *cobra.Command { return report(resp) }, } - cmd.AddCommand(list, create, update, del, order) + color := &cobra.Command{ + Use: "color VIEW COLOR", + Short: "Set the colour the web UI shows a view in", + Long: "Set the colour the web UI shows a view in, so zones of the same view are easy to spot.\n" + + "Colours: default (the link colour), " + strings.Join(model.ViewColors, ", ") + ".\n" + + "This only changes the web UI: it applies at once, with no changeset or deploy.", + Example: ` jarl view color internal green + jarl view color internal default`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + col := strings.ToLower(args[1]) + if col == "default" { + col = "" + } else if !slices.Contains(model.ViewColors, col) { + return fmt.Errorf("unknown colour %q: use default, %s", args[1], strings.Join(model.ViewColors, ", ")) + } + var v api.View + if err := c.Do(ctx(), "PUT", "/views/"+url.PathEscape(args[0])+"/color", nil, api.ViewColor{Color: col}, &v); err != nil { + return err + } + fmt.Printf("View %s: colour %s\n", v.Name, orDefault(v.Color)) + return nil + }, + } + cmd.AddCommand(list, create, update, del, order, color) return cmd } +func orDefault(s string) string { + if s == "" { + return "default" + } + return s +} + func orAny(list []string) string { if len(list) == 0 { return "any" diff --git a/docs/api.md b/docs/api.md index 3903343..bc8fb5d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -235,10 +235,20 @@ Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text` | POST | `/views` | `{"name": "internal", "match_clients": ["homelab", "localhost"], "match_destinations": [], "match_recursive_only": null, "comment": "", "adopt_zones": true}` | | GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones | | POST | `/views/order` | `{"ids": [3, 1, 2]}` | +| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default | `adopt_zones` moves zones that are not in a view into the new view. named requires every zone to be in a view as soon as one view exists. +`color` is how the web UI shows the view: one of `green`, `teal`, +`purple`, `pink`, `orange`, `yellow`, `red` and `grey`. When it's left out, +the view uses the link colour. It isn't part of the BIND configuration, so +`PUT /views/{id}/color` applies at once and creates no changeset. It needs +write access to the view, and it is logged in the audit log as +`view.color`. To colour a view that so far exists only in a changeset, +send the request with that changeset (`?changeset=` or +`X-Jarl-Changeset`). + ### ACLs and TSIG keys | Method | Path | Body | diff --git a/docs/cli.md b/docs/cli.md index cf06023..1f5f5f8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -300,6 +300,7 @@ jarl view create external --match-clients any jarl view order internal external jarl view update external --match-clients "!homelab,any" jarl view delete old [--cascade] +jarl view color internal green # web UI colour; "default" resets it jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16 diff --git a/docs/views.md b/docs/views.md index b4fc626..bc79f5c 100644 --- a/docs/views.md +++ b/docs/views.md @@ -130,5 +130,10 @@ On the server, `dig -b ` picks which view you test. `jarl view list` shows it. * Deleting a view is refused while it still has zones, unless you pass `--cascade`. +* Give each view a colour (Views → Edit → Colour, or + `jarl view color internal green`) to tell the zones of each view apart + in the zone list. The colour only affects the web UI, so it applies at + once, with no changeset or deploy. `jarl view color internal default` + goes back to the link colour. * Zone files are stored per view: `/var/lib/bind/jarl/internal/adbraun.lol.zone`, `/var/lib/bind/jarl/external/adbraun.lol.zone`. diff --git a/internal/api/types.go b/internal/api/types.go index 08806d0..143e376 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -41,7 +41,13 @@ type Record struct { // View is a view with its zone count. type View struct { model.View - Zones int `json:"zone_count"` + Zones int `json:"zone_count"` + Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors +} + +// ViewColor sets the colour the web UI shows a view in. +type ViewColor struct { + Color string `json:"color"` } // ACL is an ACL with the objects that use it. diff --git a/internal/model/model.go b/internal/model/model.go index e66bb27..9f9adbb 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -37,6 +37,10 @@ func (m *Meta) Touch(user string, now time.Time) { } // View is a BIND view. +// ViewColors are the colours the web UI can show a view in, besides the +// default (the link colour). +var ViewColors = []string{"green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"} + type View struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/internal/server/h_config.go b/internal/server/h_config.go index fc779f4..514bd46 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "net/http" + "slices" "sort" "strconv" "strings" @@ -46,10 +47,14 @@ func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Princ if err != nil { return err } + colors, err := s.store.ViewColors(r.Context()) + if err != nil { + return err + } out := []api.View{} for _, v := range st.OrderedViews() { if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) { - out = append(out, presentView(st, v)) + out = append(out, presentView(st, v, colors)) } } writeJSON(w, http.StatusOK, out) @@ -79,14 +84,58 @@ func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Princip if err != nil { return err } - writeJSON(w, http.StatusOK, presentView(st, v)) + colors, err := s.store.ViewColors(r.Context()) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentView(st, v, colors)) return nil } -func viewObject(st *model.State, id int64) any { - if v := st.Views[id]; v != nil { - return presentView(st, v) +func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any { + return func(st *model.State, id int64) any { + if v := st.Views[id]; v != nil { + colors, _ := s.store.ViewColors(r.Context()) + return presentView(st, v, colors) + } + return nil } +} + +// setViewColor sets the colour the web UI shows a view in. It is not part +// of the configuration, so it applies at once, outside changesets. +func (s *Server) setViewColor(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + v, err := s.viewByPath(r, st, p) + if err != nil { + return err + } + if err := require(p, auth.Action{Resource: auth.ResViews, Write: true, View: v.Name}); err != nil { + return err + } + var in api.ViewColor + if err := decodeJSON(r, &in); err != nil { + return err + } + if in.Color != "" && !slices.Contains(model.ViewColors, in.Color) { + return badRequest("unknown colour %q: use one of %s, or \"\" for the default", in.Color, strings.Join(model.ViewColors, ", ")) + } + if err := s.store.SetViewColor(r.Context(), v.ID, in.Color); err != nil { + return err + } + detail := in.Color + if detail == "" { + detail = "default" + } + s.eng.Audit(r.Context(), p, "view.color", "view", v.ID, v.Name, "success", detail) + colors, err := s.store.ViewColors(r.Context()) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, presentView(st, v, colors)) return nil } @@ -95,7 +144,7 @@ func (s *Server) createView(w http.ResponseWriter, r *http.Request, p *auth.Prin if err != nil { return err } - return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewCreate, 0, body)}, viewObject) + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewCreate, 0, body)}, s.viewObject(r)) } func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { @@ -112,7 +161,7 @@ func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Prin return err } id := v.ID - return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return viewObject(st, id) }) + return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return s.viewObject(r)(st, id) }) } func (s *Server) deleteView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { diff --git a/internal/server/present.go b/internal/server/present.go index 760c491..92eccec 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -41,8 +41,8 @@ func canReadRecord(p *auth.Principal, st *model.State, r *model.Record) bool { return z != nil && p.Can(auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z), Name: r.Name, Type: r.Type}) } -func presentView(st *model.State, v *model.View) api.View { - return api.View{View: *v, Zones: len(st.ZonesInView(v.ID))} +func presentView(st *model.State, v *model.View, colors map[int64]string) api.View { + return api.View{View: *v, Zones: len(st.ZonesInView(v.ID)), Color: colors[v.ID]} } func presentACL(st *model.State, a *model.ACL) api.ACL { diff --git a/internal/server/routes.go b/internal/server/routes.go index 7107ab5..30e2b10 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -47,6 +47,7 @@ func (s *Server) routes() { m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView)) m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView)) m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView)) + m.HandleFunc("PUT "+p+"/views/{id}/color", s.authed(s.setViewColor)) // ACLs m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs)) m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL)) diff --git a/internal/server/viewcolor_test.go b/internal/server/viewcolor_test.go new file mode 100644 index 0000000..39b6051 --- /dev/null +++ b/internal/server/viewcolor_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "testing" +) + +func TestViewColors(t *testing.T) { + e := newAPIEnv(t) + in := e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)["object"].(map[string]any) + ex := e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)["object"].(map[string]any) + inID, exID := int64(in["id"].(float64)), int64(ex["id"].(float64)) + applied := len(e.list("/changesets?status=applied", e.token)) + + v := e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": "green"}, 200) + if v["color"] != "green" || v["name"] != "internal" { + t.Errorf("set colour = %v", v) + } + e.do("PUT", "/views/"+itoa(exID)+"/color", e.token, map[string]any{"color": "purple"}, 200) + got := map[string]any{} + for _, v := range e.list("/views", e.token) { + got[v["name"].(string)] = v["color"] + } + if got["internal"] != "green" || got["external"] != "purple" { + t.Errorf("listed colours = %v", got) + } + if v := e.do("GET", "/views/internal", e.token, nil, 200); v["color"] != "green" { + t.Errorf("get view colour = %v", v["color"]) + } + // A colour is not configuration: no changeset. + if n := len(e.list("/changesets?status=applied", e.token)); n != applied { + t.Errorf("colour changes made changesets: %d -> %d", applied, n) + } + if v := e.do("PATCH", "/views/internal", e.token, map[string]any{"comment": "LAN"}, 200)["object"].(map[string]any); v["color"] != "green" { + t.Errorf("colour after update = %v", v["color"]) + } + if _, status := e.req("PUT", "/views/internal/color", e.token, map[string]any{"color": "#ff0000"}); status != 400 { + t.Errorf("bad colour = %d", status) + } + if _, status := e.req("PUT", "/views/nope/color", e.token, map[string]any{"color": "red"}); status != 404 { + t.Errorf("unknown view = %d", status) + } + + // Readers of views can't change colours. + e.do("POST", "/users", e.token, map[string]any{"username": "reader", "password": "reader-password-123", "grant_strings": []string{"views:read"}}, 201) + var readerID int64 + for _, u := range e.list("/users", e.token) { + if u["username"] == "reader" { + readerID = int64(u["id"].(float64)) + } + } + rt := e.do("POST", "/users/"+itoa(readerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string) + if _, status := e.req("PUT", "/views/internal/color", rt, map[string]any{"color": "red"}); status != 403 { + t.Errorf("reader set a colour: %d", status) + } + + // A view created in a draft can be given a colour before it is applied. + cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "dmz"}, 201)["id"].(float64))) + e.do("POST", "/views?changeset="+cs, e.token, map[string]any{"name": "dmz"}, 202) + if _, status := e.req("PUT", "/views/dmz/color", e.token, map[string]any{"color": "orange"}); status != 404 { + t.Errorf("draft view found outside its changeset: %d", status) + } + e.do("PUT", "/views/dmz/color?changeset="+cs, e.token, map[string]any{"color": "orange"}, 200) + e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": "teal"}, 200) + e.do("POST", "/changesets/"+cs+"/apply", e.token, nil, 200) + if v := e.do("GET", "/views/dmz", e.token, nil, 200); v["color"] != "orange" { + t.Errorf("draft view colour after apply = %v", v["color"]) + } + + // "" goes back to the default. + if v := e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": ""}, 200); v["color"] != nil { + t.Errorf("reset colour = %v", v["color"]) + } + colors, err := e.st.ViewColors(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, ok := colors[inID]; ok { + t.Errorf("stored colours after reset = %v", colors) + } +} diff --git a/internal/store/viewcolors.go b/internal/store/viewcolors.go new file mode 100644 index 0000000..c28d41b --- /dev/null +++ b/internal/store/viewcolors.go @@ -0,0 +1,55 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "strconv" +) + +// View colours only change how the web UI shows a view, so they are kept +// outside the configuration: setting one needs no changeset or deploy. +const viewColorsKey = "view_colors" + +// ViewColors returns the colour of each view that has one, by view ID. +func (s *Store) ViewColors(ctx context.Context) (map[int64]string, error) { + out := map[int64]string{} + raw, err := s.GetMeta(ctx, viewColorsKey) + if errors.Is(err, ErrNotFound) { + return out, nil + } + if err != nil { + return nil, err + } + var m map[string]string + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, err + } + for k, v := range m { + if id, err := strconv.ParseInt(k, 10, 64); err == nil { + out[id] = v + } + } + return out, nil +} + +// SetViewColor sets a view's colour; "" removes it. View IDs are never +// reused, so the colour of a deleted view is harmless and is left alone +// (the view may still exist in a changeset). +func (s *Store) SetViewColor(ctx context.Context, id int64, color string) error { + m, err := s.ViewColors(ctx) + if err != nil { + return err + } + if color == "" { + delete(m, id) + } else { + m[id] = color + } + out := map[string]string{} + for k, v := range m { + out[strconv.FormatInt(k, 10)] = v + } + b, _ := json.Marshal(out) + return s.SetMeta(ctx, viewColorsKey, string(b)) +} diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index a118041..864ee88 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -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 { diff --git a/internal/web/static/assets/pages/history.js b/internal/web/static/assets/pages/history.js index 61c3b6d..c3c9da5 100644 --- a/internal/web/static/assets/pages/history.js +++ b/internal/web/static/assets/pages/history.js @@ -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" : "")), diff --git a/internal/web/static/assets/pages/views.js b/internal/web/static/assets/pages/views.js index ea784ae..c6b6bef 100644 --- a/internal/web/static/assets/pages/views.js +++ b/internal/web/static/assets/pages/views.js @@ -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) { diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index 6e99c4d..d1af3f2 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -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" }, diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index 6ebe7c5..e11a401 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -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" }, diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 74f338d..c4f77dc 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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); } diff --git a/internal/web/static/assets/ui.js b/internal/web/static/assets/ui.js index 5d852e7..5492da1 100644 --- a/internal/web/static/assets/ui.js +++ b/internal/web/static/assets/ui.js @@ -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; +}