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:
abrown38andClaude Opus 5.5 committed 2026-10-09 14:43:57 +00:00
1 parent b22ffee1bb
commit b05711b13e
20 files changed
+393 -30

No files matched your search

+56 -7
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+1
View File
@@ -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))
+81
View File
@@ -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)
}
}