Merge branch 'view-ui'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 15:17:49 +00:00
commit ca0f148b78
29 files changed
+623 -55

No files matched your search

+2 -1
View File
@@ -13,6 +13,7 @@ import (
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/store"
"jarl/internal/version"
)
func toRequests(in []api.ChangeRequest) []engine.Request {
@@ -273,7 +274,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
}
}
now := time.Now()
resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
resp := api.StatusResponse{Version: s.cfg.Version, Build: version.Info(), Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
if p.IsAdmin() {
resp.Backup = s.backupStatus(r.Context(), now)
resp.TOTP = s.totpOverview(r.Context())
+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 {
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/rr"
"jarl/internal/version"
)
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone {
@@ -44,8 +45,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 {
@@ -142,7 +143,7 @@ func (s *Server) userOut(u *model.User) api.User {
}
func (s *Server) whoamiOf(ctx context.Context, p *auth.Principal, se *model.Session) api.Whoami {
out := api.Whoami{User: s.userOut(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version}
out := api.Whoami{User: s.userOut(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version, Build: version.Info()}
if p.Method == "session" {
st := s.totpStatus(ctx, p.User)
out.TOTP = &st
+1
View File
@@ -54,6 +54,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)
}
}