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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user