feat(master): Ledger UI for Services and Agents (#18)

Restyles the master UI after the Ledger design: a sidebar with live counts,
a machine filter, a Live box and a light/dark toggle; the overview groups
services per Swarm cluster and machine with icons, search and the
Icon / Icon + text / Text switch; the agents page gets a revoke popover and
an add-agent panel with a connect step. Jobs, Job and Service pages get the
new shell and tokens only.

- fonts and Simple Icons are served by the master (no CDN)
- the overview reads agents too, to show offline banners and cluster managers
- revoking an agent also publishes the agents event
- the SSE keepalive is a named event (ping) the Live box counts
- the master passes its build version to the UI so agents on another
  version are marked old

Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
This commit is contained in:
2026-10-13 10:18:24 +02:00
parent 11c876af7f
commit dd2b90bece
13 changed files with 1565 additions and 189 deletions
+190 -29
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
@@ -17,6 +18,7 @@ import (
"github.com/shcizo/package-updater/internal/agenttoken"
"github.com/shcizo/package-updater/internal/master/events"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/web/icons"
"github.com/shcizo/package-updater/internal/master/webauth"
)
@@ -72,12 +74,14 @@ type Broker interface {
Publish(t events.Topic)
}
// Config holds values shown in the "add agent" snippet and on the service page.
// Config holds values shown in the "add agent" snippet, on the agents page and
// on the service page.
type Config struct {
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
MasterKey string // the master's fingerprint
AgentImage string
PollInterval time.Duration // shown as "every <interval>" on the service page
Version string // the master's build version; agents on another version are marked old
}
// Server renders the UI.
@@ -95,9 +99,9 @@ type Server struct {
// New parses the templates. Each page is its own template set so every page
// can define its own "content" block.
func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag}
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle}
pages := map[string]*template.Template{}
for _, page := range []string{"overview.html", "agents.html", "agent_created.html", "service.html", "jobs.html", "job.html"} {
for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} {
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", page, err)
@@ -124,20 +128,57 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
route("GET /fragments/jobs/{id}", s.jobFragment)
route("GET /agents", s.agents)
route("GET /fragments/agents", s.agentsFragment)
route("GET /fragments/agents/{id}/connect", s.agentConnectFragment)
route("GET /fragments/nav", s.navFragment)
route("POST /agents", s.createAgent)
route("POST /agents/{id}/revoke", s.revokeAgent)
route("GET /events", s.events)
}
// navData is what the sidebar shows on every page.
type navData struct {
Section string // services, jobs or agents; "" for none
Services int
AgentsOnline int
AgentsTotal int // agents that are not revoked
}
// spriteIcon is one icon of the sprite the services fragment carries.
type spriteIcon struct {
Slug string
Path string
}
// sections maps a page to the sidebar entry that is current on it.
var sections = map[string]string{
"overview.html": "services", "service.html": "services",
"jobs.html": "jobs", "job.html": "jobs",
"agents.html": "agents",
}
type pageData struct {
Title string
CSRF string
Groups []serviceGroup
Title string
CSRF string
Nav navData
Groups []serviceGroup
// ServiceCount is how many services the groups hold.
ServiceCount int
// Sprite holds the icons the groups use, so the page carries only those.
Sprite []spriteIcon
Agents []agentRow
Error string
Created model.Agent
Snippet string
// AddStep is the step of the "add agent" panel: 0 closed, 1 name,
// 2 deploy (Created, Token, MasterKey and Snippet are set).
AddStep int
FormName string // what was typed in step 1
Token string
MasterKey string
Connected bool // the agent Created has connected
MasterVersion string // the master's build version; "" if unknown
Service model.Service
ConfigFiles []string
Reason string // why Service cannot be updated; "" when it can
@@ -154,6 +195,58 @@ func (s *Server) base(r *http.Request, title string) pageData {
return pageData{Title: title, CSRF: sess.CSRFToken}
}
// page renders a full page: the sidebar numbers are looked up and put in data.
func (s *Server) page(w http.ResponseWriter, r *http.Request, status int, page string, data pageData) {
nav, err := s.navData(r.Context(), sections[page])
if err != nil {
s.fail(w, err)
return
}
data.Nav = nav
s.render(w, status, page, "layout", data)
}
// navData counts what the sidebar shows next to its entries.
func (s *Server) navData(ctx context.Context, section string) (navData, error) {
nav := navData{Section: section}
services, err := s.store.ListServices(ctx)
if err != nil {
return nav, err
}
nav.Services = len(services)
agents, err := s.store.ListAgents(ctx)
if err != nil {
return nav, err
}
for _, a := range agents {
if a.RevokedAt != nil {
continue
}
nav.AgentsTotal++
if s.hub.Online(a.ID) {
nav.AgentsOnline++
}
}
return nav, nil
}
// navFragment renders the sidebar entries. The page asks for it with its own
// section so the current entry stays marked.
func (s *Server) navFragment(w http.ResponseWriter, r *http.Request) {
section := r.URL.Query().Get("section")
if section != "services" && section != "jobs" && section != "agents" {
section = ""
}
nav, err := s.navData(r.Context(), section)
if err != nil {
s.fail(w, err)
return
}
data := s.base(r, "")
data.Nav = nav
s.render(w, http.StatusOK, "overview.html", "nav", data)
}
func (s *Server) render(w http.ResponseWriter, status int, page, block string, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
@@ -168,17 +261,41 @@ func (s *Server) serviceData(r *http.Request) (pageData, error) {
if err != nil {
return data, err
}
data.Groups = groupServices(services, s.autoBlocked)
agents, err := s.store.ListAgents(r.Context())
if err != nil {
return data, err
}
data.Groups = groupServices(services, agents, s.hub.Online, s.autoBlocked)
data.ServiceCount = len(services)
data.Sprite = spriteFor(data.Groups)
return data, nil
}
// spriteFor collects the icons the groups' rows use, each once.
func spriteFor(groups []serviceGroup) []spriteIcon {
var sprite []spriteIcon
seen := map[string]bool{}
for _, g := range groups {
for _, row := range g.Services {
if row.Icon == "" || seen[row.Icon] {
continue
}
seen[row.Icon] = true
if path, ok := icons.Path(row.Icon); ok {
sprite = append(sprite, spriteIcon{Slug: row.Icon, Path: path})
}
}
}
return sprite
}
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
data, err := s.serviceData(r)
if err != nil {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "overview.html", "layout", data)
s.page(w, r, http.StatusOK, "overview.html", data)
}
func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
@@ -187,7 +304,7 @@ func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "overview.html", "services", data)
s.render(w, http.StatusOK, "overview.html", "services-fragment", data)
}
// autoBlocked returns the reason svc cannot be set to auto mode, or "".
@@ -255,7 +372,7 @@ func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) {
s.failLookup(w, err)
return
}
s.render(w, http.StatusOK, "service.html", "layout", data)
s.page(w, r, http.StatusOK, "service.html", data)
}
func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) {
@@ -302,7 +419,7 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
return
}
data.Error = "Cannot update: " + nu.Reason + "."
s.render(w, http.StatusConflict, "service.html", "layout", data)
s.page(w, r, http.StatusConflict, "service.html", data)
default:
s.failLookup(w, err)
}
@@ -337,7 +454,7 @@ func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
return
}
data.Error = "Cannot enable auto: " + nu.Reason + "."
s.render(w, http.StatusConflict, "service.html", "layout", data)
s.page(w, r, http.StatusConflict, "service.html", data)
return
} else if err != nil {
s.fail(w, err)
@@ -369,7 +486,7 @@ func (s *Server) jobsPage(w http.ResponseWriter, r *http.Request) {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "jobs.html", "layout", data)
s.page(w, r, http.StatusOK, "jobs.html", data)
}
func (s *Server) jobsFragment(w http.ResponseWriter, r *http.Request) {
@@ -400,7 +517,7 @@ func (s *Server) jobData(w http.ResponseWriter, r *http.Request) (pageData, bool
func (s *Server) jobPage(w http.ResponseWriter, r *http.Request) {
if data, ok := s.jobData(w, r); ok {
s.render(w, http.StatusOK, "job.html", "layout", data)
s.page(w, r, http.StatusOK, "job.html", data)
}
}
@@ -412,23 +529,28 @@ func (s *Server) jobFragment(w http.ResponseWriter, r *http.Request) {
func (s *Server) agentData(r *http.Request) (pageData, error) {
data := s.base(r, "Agents")
data.MasterVersion = s.cfg.Version
agents, err := s.store.ListAgents(r.Context())
if err != nil {
return data, err
}
for _, a := range agents {
data.Agents = append(data.Agents, agentRow{Agent: a, Online: s.hub.Online(a.ID)})
data.Agents = append(data.Agents, newAgentRow(a, s.hub.Online(a.ID), s.cfg.Version))
}
return data, nil
}
// agents shows the agent list; with ?add=1 the "add agent" panel is open.
func (s *Server) agents(w http.ResponseWriter, r *http.Request) {
data, err := s.agentData(r)
if err != nil {
s.fail(w, err)
return
}
s.render(w, http.StatusOK, "agents.html", "layout", data)
if r.URL.Query().Get("add") == "1" {
data.AddStep = 1
}
s.page(w, r, http.StatusOK, "agents.html", data)
}
func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
@@ -440,10 +562,34 @@ func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "agents.html", "agents", data)
}
// agentConnectFragment is the last step of the "add agent" panel: it says
// whether the agent that was just added has connected. It never repeats the
// token.
func (s *Server) agentConnectFragment(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
agents, err := s.store.ListAgents(r.Context())
if err != nil {
s.fail(w, err)
return
}
i := slices.IndexFunc(agents, func(a model.Agent) bool { return a.ID == id && a.RevokedAt == nil })
if i < 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
data := s.base(r, "Agents")
data.Created = agents[i]
data.Connected = s.hub.Online(id) || agents[i].LastSeenAt != nil
s.render(w, http.StatusOK, "agents.html", "connect", data)
}
func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
if !agentNamePattern.MatchString(name) {
s.agentsWithError(w, r, http.StatusBadRequest, "Agent names use lowercase letters, digits and dashes.")
s.agentsWithError(w, r, http.StatusBadRequest, name, "Agent names use lowercase letters, digits and dashes.")
return
}
token, err := agenttoken.Generate()
@@ -453,28 +599,39 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
}
agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token))
if errors.Is(err, model.ErrConflict) {
s.agentsWithError(w, r, http.StatusConflict, fmt.Sprintf("An agent named %q already exists.", name))
s.agentsWithError(w, r, http.StatusConflict, name, fmt.Sprintf("An agent named %q already exists.", name))
return
}
if err != nil {
s.fail(w, err)
return
}
data := s.base(r, "Agent created")
data.Created = agent
data.Snippet = agentSnippet(s.cfg, token)
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
s.render(w, http.StatusOK, "agent_created.html", "layout", data)
}
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, msg string) {
data, err := s.agentData(r)
if err != nil {
s.fail(w, err)
return
}
data.AddStep = 2
data.Created = agent
data.Token = token
data.MasterKey = s.cfg.MasterKey
data.Snippet = agentSnippet(s.cfg, token)
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
s.page(w, r, http.StatusOK, "agents.html", data)
}
// agentsWithError shows the agent list with the "add agent" panel open at
// its first step, name kept and msg under the field.
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, name, msg string) {
data, err := s.agentData(r)
if err != nil {
s.fail(w, err)
return
}
data.AddStep = 1
data.FormName = name
data.Error = msg
s.render(w, status, "agents.html", "layout", data)
s.page(w, r, status, "agents.html", data)
}
func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
@@ -489,6 +646,7 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
return
}
s.hub.Disconnect(id)
s.broker.Publish(events.Agents) // the agent list and the sidebar change
s.broker.Publish(events.Services) // the agent's services were marked removed
s.broker.Publish(events.Jobs) // the agent's active jobs were failed
for _, jobID := range failedJobs {
@@ -497,6 +655,9 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/agents", http.StatusSeeOther)
}
// pingInterval is how often an idle event stream sends "event: ping".
var pingInterval = 25 * time.Second
// events streams change signals as Server-Sent Events. htmx's SSE extension
// turns "event: services" into a refetch of the services fragment. The event
// carries no state, so a dropped signal is recovered by the next one.
@@ -516,7 +677,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
s.log.Error("sse_flush_unsupported", "error", err)
return
}
keepalive := time.NewTicker(25 * time.Second)
keepalive := time.NewTicker(pingInterval)
defer keepalive.Stop()
for {
var msg string
@@ -526,7 +687,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
case t := <-ch:
msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t)
case <-keepalive.C:
msg = ": keepalive\n\n"
msg = "event: ping\ndata: ok\n\n" // keeps proxies open; the Live box in the sidebar counts it
}
if _, err := fmt.Fprint(w, msg); err != nil {
return