diff --git a/cmd/master/main.go b/cmd/master/main.go index 821c495..36bfe90 100644 --- a/cmd/master/main.go +++ b/cmd/master/main.go @@ -117,7 +117,7 @@ func run() error { ui, err := web.New(st, agentHub, jobService, broker, web.Config{ AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage, - PollInterval: cfg.PollInterval, + PollInterval: cfg.PollInterval, Version: version, }, logger) if err != nil { return err diff --git a/internal/master/web/ledger_test.go b/internal/master/web/ledger_test.go new file mode 100644 index 0000000..404eb89 --- /dev/null +++ b/internal/master/web/ledger_test.go @@ -0,0 +1,127 @@ +package web + +import ( + "bufio" + "context" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvents_SendsPingWhenIdle(t *testing.T) { + old := pingInterval + pingInterval = 20 * time.Millisecond + t.Cleanup(func() { pingInterval = old }) + srv, _, _, _ := newTestServer(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadString('\n') + require.NoError(t, err) + if strings.HasPrefix(line, "event:") { + assert.Equal(t, "event: ping\n", line, "the Live box counts a named event; a comment would not reach the browser's listeners") + return + } + } +} + +func TestCreateAgent_ValidationShowsInsideTheAddPanel(t *testing.T) { + srv, _, _, _ := newTestServer(t) + resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {"node-2"}}) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, http.StatusConflict, resp.StatusCode) + assert.Contains(t, string(body), `class="drawer"`) + assert.Contains(t, string(body), `value="node-2"`, "the typed name is kept") + assert.NotContains(t, string(body), "pua_", "no token is shown") +} + +func TestNavFragment(t *testing.T) { + srv, _, _, _ := newTestServer(t) + cases := []struct { + query, want, notWant string + }{ + {"?section=agents", `Agents 1/3`, `Services 4`, `Jobs`, `class="cur"`}, + } + for _, c := range cases { + t.Run(c.query, func(t *testing.T) { + code, body := get(t, srv, "/fragments/nav"+c.query) + assert.Equal(t, http.StatusOK, code) + assert.NotContains(t, body, "" 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 diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index 8be45c6..c5c1626 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -211,30 +211,46 @@ func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.B // the service page shows. func newTestEnvPolling(t *testing.T, pollInterval time.Duration) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { t.Helper() - agentID := int64(2) + return newTestEnvConfig(t, func(c *Config) { c.PollInterval = pollInterval }) +} + +// newTestEnvVersion is newTestEnv with version as the master's build version. +func newTestEnvVersion(t *testing.T, version string) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { + t.Helper() + return newTestEnvConfig(t, func(c *Config) { c.Version = version }) +} + +// newTestEnvConfig is newTestEnv with edit applied to the UI's Config. +func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { + t.Helper() + agentID, offlineAgentID := int64(2), int64(3) store := &fakeStore{ created: map[string]string{"node-2": "x"}, services: []model.Service{ {ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2", ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"}, - {ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin", + {ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin", SwarmClusterID: "c1abcdefgh", ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"}, {ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2", ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true}, + {ID: 4, Kind: protocol.KindCompose, ComposeProject: "media", ComposeService: "radarr", AgentID: &offlineAgentID, AgentName: "node-3", + ActualRef: "lscr.io/linuxserver/radarr:latest", Status: "running", UpdateMode: "manual"}, }, agents: []model.Agent{ - {ID: 2, Name: "node-2", Hostname: "node-2"}, - {ID: 3, Name: "node-3"}, + {ID: 2, Name: "node-2", Hostname: "node-2.lan", AgentVersion: "v1.4.0", SwarmManager: true, SwarmClusterID: "c1abcdefgh", LastSeenAt: ptr(time.Now().UTC())}, + {ID: 3, Name: "node-3", Hostname: "node-3.lan", AgentVersion: "v1.3.2", LastSeenAt: ptr(time.Date(2026, 10, 12, 13, 51, 0, 0, time.UTC))}, {ID: 4, Name: "old", RevokedAt: ptr(time.Now())}, + {ID: 5, Name: "fresh"}, }, } hub := &fakeHub{online: map[int64]bool{2: true}} broker := events.NewBroker() jobs := &fakeJobs{} - s, err := New(store, hub, jobs, broker, Config{ + cfg := Config{ AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest", - PollInterval: pollInterval, - }, slog.New(slog.NewTextHandler(io.Discard, nil))) + } + edit(&cfg) + s, err := New(store, hub, jobs, broker, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) require.NoError(t, err) mux := http.NewServeMux() s.Register(mux, func(h http.Handler) http.Handler { @@ -269,13 +285,44 @@ func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) { code, body := get(t, srv, "/") assert.Equal(t, http.StatusOK, code) assert.Contains(t, body, "Swarm", "one cluster is just called Swarm") + assert.Contains(t, body, `title="c1abcdefgh"`, "the full cluster id is the hover text") + assert.Contains(t, body, "managers node-2") + assert.Contains(t, body, ">node-2") + assert.Contains(t, body, `web`) + assert.Contains(t, body, `slusk`) + assert.Contains(t, body, "jellyfin_jellyfin", "Swarm service names are shown as they are") assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened") - assert.Contains(t, body, "managed outside") - assert.Less(t, strings.Index(body, "node-2"), strings.Index(body, "Swarm"), "machines before Swarm") + assert.Contains(t, body, "Managed outside") + assert.Less(t, strings.Index(body, ">Swarm"), strings.Index(body, ">node-2"), "a cluster is followed by its managers' machines") + assert.Contains(t, body, `class="sub" data-machine="agent-2"`, "the machine filter lists the manager under the cluster") +} + +func TestOverview_OfflineGroupShowsSince(t *testing.T) { + srv, store, hub, _, _ := newTestEnv(t) + _, body := get(t, srv, "/") + assert.Regexp(t, `Agent offline since · showing last known state`, body) + assert.NotContains(t, body, "No manager online", "the cluster has a connected manager") + + hub.mu.Lock() + hub.online[2] = false + hub.mu.Unlock() + _, body = get(t, srv, "/") + since := store.agents[0].LastSeenAt.Format("2006-01-02T15:04:05Z07:00") + assert.Contains(t, body, `No manager online since