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
+1 -1
View File
@@ -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
+127
View File
@@ -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", `<a href="/agents" class="cur" aria-current="page">Agents <span class="n">1/3</span>`, `<a href="/" class="cur"`},
{"?section=services", `<a href="/" class="cur" aria-current="page">Services <span class="n">4</span>`, `<a href="/agents" class="cur"`},
{"?section=bogus", `<a href="/jobs">Jobs</a>`, `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, "<html")
assert.Contains(t, body, c.want)
assert.NotContains(t, body, c.notWant)
})
}
_, body := get(t, srv, "/agents")
assert.Contains(t, body, `hx-get="/fragments/nav?section=agents"`, "every page refreshes the sidebar on services and agents events")
assert.Contains(t, body, `hx-trigger="sse:services, sse:agents"`)
}
func TestAgentConnectFragment(t *testing.T) {
cases := []struct {
name string
path string
online bool
wantCode int
want []string
notWant []string
}{
{"never connected waits", "/fragments/agents/5/connect", false, http.StatusOK, []string{"Waiting for fresh", `action="/agents/5/revoke"`}, []string{"Connected", "add-steps"}},
{"online is connected", "/fragments/agents/5/connect", true, http.StatusOK, []string{"Connected · ", `id="add-steps" hx-swap-oob="innerHTML"`}, []string{"Waiting", "revoke"}},
{"seen before is connected", "/fragments/agents/3/connect", false, http.StatusOK, []string{"Connected · node-3.lan · v1.3.2"}, []string{"Waiting"}},
{"unknown", "/fragments/agents/99/connect", false, http.StatusNotFound, nil, nil},
{"revoked", "/fragments/agents/4/connect", false, http.StatusNotFound, nil, nil},
{"bad id", "/fragments/agents/abc/connect", false, http.StatusBadRequest, nil, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv, _, hub, _, _ := newTestEnv(t)
hub.mu.Lock()
hub.online[5] = c.online
hub.mu.Unlock()
code, body := get(t, srv, c.path)
assert.Equal(t, c.wantCode, code)
for _, w := range c.want {
assert.Contains(t, body, w)
}
for _, w := range c.notWant {
assert.NotContains(t, body, w)
}
assert.NotContains(t, body, "pua_", "the token is never sent again")
})
}
}
func TestStatic_FontsAndAppJS(t *testing.T) {
srv, _, _, _ := newTestServer(t)
for _, path := range []string{
"/static/app.js", "/static/app.css",
"/static/schibsted-grotesk-latin-wght-normal.woff2", "/static/jetbrains-mono-latin-wght-normal.woff2",
"/static/OFL-schibsted-grotesk.txt", "/static/OFL-jetbrains-mono.txt",
} {
code, _ := get(t, srv, path)
assert.Equal(t, http.StatusOK, code, path)
}
_, css := get(t, srv, "/static/app.css")
assert.NotContains(t, css, "fonts.googleapis.com", "fonts are served by the master")
assert.Contains(t, css, "format('woff2')")
_, page := get(t, srv, "/")
assert.NotContains(t, page, "fonts.googleapis.com")
assert.Contains(t, page, `src="/static/app.js"`)
}
+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
+121 -25
View File
@@ -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, "<html")
assert.Contains(t, body, "node-2")
assert.Contains(t, body, "Swarm")
assert.Contains(t, body, "slusk/web")
assert.Contains(t, body, "jellyfin_jellyfin")
assert.Contains(t, body, ">Swarm</h2>", "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</h2>")
assert.Contains(t, body, `<span class="nmt">web</span>`)
assert.Contains(t, body, `<span class="stk">slusk</span>`)
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</h2>"), strings.Index(body, ">node-2</h2>"), "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 <time datetime="2026-10-12T13:51:00Z">13:51</time> · 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 <time datetime="`+since+`">`)
assert.Contains(t, body, ` · showing last known state`)
}
func TestOverview_IconOrLetter(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ActualRef = "gitea/gitea:1.27.3"
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Contains(t, body, `<symbol id="si-gitea"`, path)
assert.Equal(t, 1, strings.Count(body, `<symbol id="si-gitea"`), "the sprite holds each icon once: "+path)
assert.Contains(t, body, `href="#si-gitea"`, path)
assert.Contains(t, body, `class="ltr lc-`, "services without an icon get a letter: "+path)
}
}
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
@@ -283,15 +330,57 @@ func TestServicesFragment_IsNotAFullPage(t *testing.T) {
code, body := get(t, srv, "/fragments/services")
assert.Equal(t, http.StatusOK, code)
assert.NotContains(t, body, "<html")
assert.Contains(t, body, "slusk/web")
assert.Contains(t, body, `title="slusk/web"`)
assert.Contains(t, body, `id="machines" hx-swap-oob`, "the machine filter in the sidebar is refreshed with the rows")
}
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
srv, _, _, _ := newTestServer(t)
_, body := get(t, srv, "/agents")
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*online`), body)
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*offline`), body)
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*revoked`), body)
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*Online`), body)
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*Offline`), body)
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*Revoked`), body)
assert.Regexp(t, regexp.MustCompile(`fresh[\s\S]*Never connected`), body)
assert.Contains(t, body, "Manager")
assert.Contains(t, body, "3 machines", "revoked agents are not counted")
assert.NotContains(t, body, `class="drawer"`, "the add panel is closed by default")
}
func TestAgentsPage_RevokeUsesPopover(t *testing.T) {
srv, _, _, _ := newTestServer(t)
_, body := get(t, srv, "/agents")
assert.Contains(t, body, `<details class="rv" data-id="2">`)
assert.Contains(t, body, `action="/agents/2/revoke"`)
assert.NotContains(t, body, "confirm(")
assert.NotContains(t, body, `action="/agents/4/revoke"`, "a revoked agent cannot be revoked again")
}
func TestAgentsPage_MasterVersionAndOldAgent(t *testing.T) {
cases := []struct {
name, version string
wantOld bool
}{
{"other version is old", "v1.4.0", true},
{"dev master marks nothing", "dev", false},
{"unknown master version marks nothing", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv, _, _, _, _ := newTestEnvVersion(t, c.version)
_, body := get(t, srv, "/agents")
assert.Equal(t, c.version != "", strings.Contains(body, "master "+c.version))
assert.Equal(t, c.wantOld, strings.Contains(body, `<div class="ver old">`+"<span class=\"k\">Agent</span>v1.3.2"), "node-3 runs v1.3.2")
assert.NotContains(t, body, `<div class="ver old"><span class="k">Agent</span>v1.4.0`, "node-2 is current")
})
}
}
func TestAgentsPage_AddPanelOpensWithQuery(t *testing.T) {
srv, _, _, _ := newTestServer(t)
_, body := get(t, srv, "/agents?add=1")
assert.Contains(t, body, `class="drawer"`)
assert.Contains(t, body, `<form method="post" action="/agents"`)
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
}
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
@@ -309,6 +398,10 @@ func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
assert.Contains(t, string(body), "MASTER_KEY: sha256:abc")
assert.Contains(t, string(body), "MASTER_URL: wss://10.0.0.5:8090/agent")
assert.Contains(t, string(body), "shown only once")
assert.Contains(t, string(body), `<pre id="snippet">`)
assert.Contains(t, string(body), `data-copy="`+token+`"`)
assert.Contains(t, string(body), `hx-get="/fragments/agents/9/connect"`, "step 3 waits for the agent")
assert.Contains(t, string(body), ">node-3</b>", "the agent list is still on the page")
}
func TestCreateAgent_Validation(t *testing.T) {
@@ -348,16 +441,16 @@ func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
assert.Equal(t, int64(2), store.revoked())
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
var got []events.Topic
for len(got) < 4 {
for len(got) < 5 {
select {
case topic := <-ch:
got = append(got, topic)
default:
t.Fatalf("revoke published %v, want services, jobs and one topic per failed job", got)
t.Fatalf("revoke published %v, want agents, services, jobs and one topic per failed job", got)
}
}
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
"the overview drops the agent's services; job lists and open job pages show the failures")
assert.ElementsMatch(t, []events.Topic{events.Agents, events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
"the agent list and sidebar change; the overview drops the agent's services; job lists and open job pages show the failures")
}
func TestRevokeAgent_BadID(t *testing.T) {
@@ -640,16 +733,19 @@ func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
store.services[1].UpdateMode = "auto"
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonSelf}
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Contains(t, body, `action="/services/1/mode"`, path)
assert.Contains(t, body, `name="csrf_token" value="csrf"`, path)
assert.Contains(t, body, `name="return" value="overview"`, path)
assert.Contains(t, body, `class="badge badge-update" title="gitea.shcizo.se/shcizo/slusk:1.5.0"`, path)
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`, "auto services toggle to manual: "+path)
assert.Contains(t, body, `disabled>Make auto</button> <span class="muted">`+model.ReasonSelf+`</span>`, "blocked manual services get a disabled button: "+path)
assert.Contains(t, body, `class="tag f-up" title="gitea.shcizo.se/shcizo/slusk:1.5.0">Update available<em>1.5.0</em>`, path)
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`+"\n"+` <button type="submit" class="sw" role="switch" aria-checked="true">`, "auto services toggle to manual: "+path)
assert.Contains(t, body, `<input type="hidden" name="mode" value="auto">`, "manual services toggle to auto: "+path)
assert.Contains(t, body, `aria-checked="false" disabled title="`+model.ReasonBuilt+`">`, "blocked manual services get a disabled switch: "+path)
assert.Contains(t, body, `<span class="na">`+model.ReasonBuilt+`</span>`, "and say why: "+path)
assert.Contains(t, body, `<span class="na">n/a</span>`, "services managed outside have no switch: "+path)
}
}
+218 -33
View File
@@ -1,34 +1,219 @@
:root { --bg: #111418; --fg: #e6e8eb; --muted: #8a929c; --line: #262b31; --ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff; }
@media (prefers-color-scheme: light) {
:root { --bg: #ffffff; --fg: #1f2328; --muted: #59636e; --line: #d1d9e0; --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da; }
@font-face{font-family:'Schibsted Grotesk';font-style:normal;font-weight:400 900;font-display:swap;src:url(/static/schibsted-grotesk-latin-wght-normal.woff2) format('woff2')}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url(/static/jetbrains-mono-latin-wght-normal.woff2) format('woff2')}
/* Ledger. Tokens live on <html class="app">; a script in <head> adds t-light before first paint. */
.app{color-scheme:dark;--bg:#0f1218;--side:#0b0d12;--card:#151a23;--hover:#171c26;--line:#232a37;--line2:#323a4a;--fg:#e7eaf1;--mut:#a0a9bb;--dim:#828ba0;--blue:#74a8ff;--bluebg:rgba(116,168,255,.12);--orange:#ffa262;--orangebg:rgba(255,162,98,.12);--green:#62d4a0;--red:#ff7d88;--redbg:rgba(255,125,136,.10);--violet:#c5a9ff;--violetbg:rgba(197,169,255,.12);--l0:#74a8ff;--l1:#c5a9ff;--l2:#5fd0e0;--l3:#f29bd0;--onblue:#06142e;--term:#080a0e;--shadow:0 12px 32px rgba(0,0,0,.5);--sans:'Schibsted Grotesk',system-ui,-apple-system,sans-serif;--mono:'JetBrains Mono',ui-monospace,Menlo,monospace;min-height:100vh;background:var(--bg);color:var(--fg);font:14px/1.45 var(--sans)}
.app.t-light{color-scheme:light;--bg:#f7f8fa;--side:#eceff4;--card:#ffffff;--hover:#f0f2f6;--line:#dde1e8;--line2:#c6ccd7;--fg:#121620;--mut:#4b5468;--dim:#5c6579;--blue:#2156c8;--bluebg:rgba(33,86,200,.08);--orange:#a84b0b;--orangebg:rgba(200,96,20,.09);--green:#177649;--red:#b32a3b;--redbg:rgba(179,42,59,.07);--violet:#6943c1;--violetbg:rgba(105,67,193,.08);--l0:#2156c8;--l1:#6943c1;--l2:#0b7285;--l3:#a8306f;--onblue:#ffffff;--term:#121620;--shadow:0 12px 32px rgba(20,30,50,.16)}
*{box-sizing:border-box}
body{margin:0}
[hidden]{display:none !important}
a{color:var(--blue)}a:hover{color:var(--fg)}
button,input{font-family:inherit}
:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
/* Shell */
.shell{display:flex;flex-wrap:wrap;min-height:100vh}
.side{flex:0 0 232px;background:var(--side);border-right:1px solid var(--line);display:flex;flex-direction:column;gap:28px;padding:20px 14px}
.brand{display:flex;align-items:center;gap:10px;font:700 15px var(--sans);letter-spacing:-.01em;padding:0 8px;color:var(--fg);text-decoration:none}
.brand svg{color:var(--blue)}
.snav{display:flex;flex-direction:column;gap:2px}
.snav a,.mlist a{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;color:var(--mut);text-decoration:none;font-weight:500}
.snav a:hover,.mlist a:hover{background:var(--hover);color:var(--fg)}
.snav a.cur,.mlist a.cur{background:var(--card);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line)}
.snav .n,.mlist .n{margin-left:auto;font:12px var(--mono);color:var(--dim)}
.slabel{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 8px 6px}
.mlist{display:flex;flex-direction:column;gap:2px}
.mlist .sub{padding-left:22px;font-size:13px}
.mdot{width:7px;height:7px;border-radius:50%;background:var(--green);flex:none}
.mdot.off{background:transparent;box-shadow:inset 0 0 0 1.5px var(--red)}
.sw-icon{width:7px;height:7px;flex:none;border-radius:2px;box-shadow:inset 0 0 0 1.5px var(--blue)}
.livebox{margin-top:auto;padding:10px 8px;border-top:1px solid var(--line);font-size:12px;color:var(--mut);display:flex;flex-direction:column;gap:2px}
.livebox b{display:flex;align-items:center;gap:8px;color:var(--fg);font-weight:600}
.pulse{width:8px;height:8px;border-radius:50%;background:var(--green);animation:breathe 2.4s ease-in-out infinite}
.livebox.down .pulse{background:var(--red);animation:none}
@keyframes breathe{0%{box-shadow:0 0 0 0 rgba(98,212,160,.45)}50%{box-shadow:0 0 0 5px rgba(98,212,160,0)}}
.sfoot{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}
.sfoot form{margin:0}
.work{flex:999 1 640px;min-width:0;display:flex;flex-wrap:wrap}
.main{flex:999 1 520px;min-width:0;padding:28px 36px 48px;container-type:inline-size}
@container (max-width:760px){
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
}
.sprite{position:absolute;width:0;height:0;overflow:hidden}
/* Page header, buttons */
.hdr{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 24px;margin-bottom:18px}
.hdr h1{margin:0;font:700 34px/1 var(--sans);letter-spacing:-.025em}
.hdr .sub{font-size:14px;color:var(--mut)}
.hdr .end{margin-left:auto}
.btn,button:not([class]){height:32px;padding:0 14px;border-radius:6px;border:1px solid var(--line2);background:var(--card);color:var(--fg);font:600 13px var(--sans);cursor:pointer;display:inline-flex;align-items:center;gap:8px;white-space:nowrap;text-decoration:none}
.btn:hover,button:not([class]):hover{border-color:var(--dim);color:var(--fg)}
button:not([class]):disabled{opacity:.5;cursor:not-allowed}
.btn.pri,.btn.pri:hover{background:var(--blue);border-color:var(--blue);color:var(--onblue)}
.btn.sm{height:28px;padding:0 10px;font-size:12.5px}
.btn.quiet{border-color:transparent;background:transparent;color:var(--mut)}
.btn.quiet:hover{color:var(--fg)}
.btn.red{color:var(--red);border-color:var(--line2);background:transparent}
.btn.redfill,.btn.redfill:hover{background:var(--red);border-color:var(--red);color:#1d0408}
summary.btn{list-style:none}
summary.btn::-webkit-details-marker{display:none}
/* Overview */
.tools{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding:12px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:8px}
.search{flex:0 1 340px;min-width:220px;display:flex;align-items:center;gap:8px;height:34px;padding:0 12px;border-radius:6px;background:var(--card);border:1px solid var(--line2);color:var(--dim)}
.search input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--fg);font-size:14px}
.search input::placeholder{color:var(--dim)}
.tools .end{margin-left:auto;display:flex;align-items:center;gap:10px}
.tools .lbl{font-size:12.5px;color:var(--dim)}
.seg{display:inline-flex;padding:2px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2)}
.seg button{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:var(--mut);font:500 12.5px var(--sans);cursor:pointer}
.seg button:hover{color:var(--fg)}
.seg button[aria-pressed=true]{background:var(--hover);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line2)}
.grp{margin-top:28px}
.gh{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding-bottom:8px;border-bottom:2px solid var(--fg)}
.gh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
.gh .kind{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--mut)}
.gh .meta{font-size:13px;color:var(--dim)}
.gh .warn{font-size:13px;color:var(--red);font-weight:500}
.gh .count{margin-left:auto;font:12px var(--mono);color:var(--dim)}
.grp.offline .gh{border-bottom-color:var(--line2)}
.grp.offline .row{color:var(--dim)}
.row{position:relative;display:grid;grid-template-columns:minmax(200px,1.4fr) minmax(200px,2fr) 130px minmax(160px,1.5fr) minmax(104px,150px);align-items:center;column-gap:18px;min-height:42px;padding:0 6px;border-bottom:1px solid var(--line)}
.row:hover{background:var(--hover)}
.nm{display:flex;align-items:center;gap:8px;min-width:0}
.nm a{display:inline-flex;align-items:center;gap:10px;min-width:0;color:var(--fg);font-weight:600;text-decoration:none;white-space:nowrap}
.nm a:hover .nmt{text-decoration:underline;text-underline-offset:3px}
.nm .nmt{overflow:hidden;text-overflow:ellipsis}
.nm .stk{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ico{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2);color:var(--fg)}
.ico svg{fill:currentColor}
.ltr{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font:700 12.5px var(--sans)}
.lc-0{color:var(--l0);background:color-mix(in srgb,var(--l0) 18%,transparent)}
.lc-1{color:var(--l1);background:color-mix(in srgb,var(--l1) 18%,transparent)}
.lc-2{color:var(--l2);background:color-mix(in srgb,var(--l2) 18%,transparent)}
.lc-3{color:var(--l3);background:color-mix(in srgb,var(--l3) 18%,transparent)}
.grp.offline .ico,.grp.offline .ltr{opacity:.65}
.d-icon .nmt,.d-icon .stk{display:none}
.d-text .ico,.d-text .ltr{display:none}
.ver{display:flex;min-width:0;font:12.5px var(--mono);color:var(--dim);white-space:nowrap}
.ver .r{overflow:hidden;text-overflow:ellipsis;min-width:0}
.ver .t{flex:none}
.ver b{color:var(--fg);font-weight:500}
.st{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:500;white-space:nowrap}
.st svg{flex:none}
.st circle{fill:currentColor}
.s-running{color:var(--green)}.s-degraded{color:var(--orange)}.s-stopped{color:var(--dim)}.s-unknown{color:var(--dim)}
.s-stopped circle,.s-unknown circle{fill:none}
.flags{display:flex;flex-wrap:wrap;gap:6px}
.tag{font:500 12px/20px var(--sans);padding:0 8px;border-radius:999px;white-space:nowrap}
.tag em{font:500 11.5px var(--mono);font-style:normal;margin-left:6px}
.tag em:empty{display:none}
.f-up{color:var(--orange);background:var(--orangebg)}
.f-err{color:var(--red);background:var(--redbg)}
.f-notag{color:var(--mut);box-shadow:inset 0 0 0 1px var(--line2)}
.f-out{color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)}
.mode{display:flex;flex-direction:column;align-items:flex-start;gap:2px}
.mode form{margin:0;display:flex;flex-direction:column;align-items:flex-start;gap:2px}
.sw{display:inline-flex;align-items:center;gap:8px;padding:0;border:0;background:none;font:inherit;font-size:13px;color:var(--mut);cursor:pointer}
.sw i{flex:none;width:30px;height:18px;border-radius:999px;background:var(--line2);position:relative;transition:background .15s}
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg);transition:transform .15s}
.sw[aria-checked=true] i{background:var(--blue)}
.sw[aria-checked=true] i::after{transform:translateX(12px);background:var(--onblue)}
.sw[aria-checked=true]{color:var(--blue);font-weight:600}
.sw:disabled{opacity:.55;cursor:not-allowed}
.na{font-size:12.5px;color:var(--dim)}
.nomatch{padding:24px 6px;color:var(--dim)}
/* Agents */
.hdr.ruled{padding-bottom:16px;border-bottom:2px solid var(--fg);margin-bottom:0}
.agent{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) 130px 90px 110px 120px 84px;grid-template-areas:"id st ver mgr last act";align-items:center;column-gap:18px;padding:16px 6px;border-bottom:1px solid var(--line)}
.agent .id{grid-area:id}
.agent .id b{display:block;font:700 16px var(--sans)}
.agent .id span{font:12.5px var(--mono);color:var(--dim)}
.agent .stc{grid-area:st}.agent .ver{grid-area:ver;display:block;font:13px var(--mono);color:var(--mut)}.agent .mgr{grid-area:mgr;font-size:13px;color:var(--mut)}.agent .last{grid-area:last;font-size:13px;color:var(--mut)}.agent .act{grid-area:act;display:flex;justify-content:flex-end}
.agent .k{display:block;font:600 10.5px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin-bottom:2px}
.agent .ver.old{color:var(--orange)}
.agent .st{font-size:13.5px;font-weight:600}
.agent .st i{width:8px;height:8px;border-radius:50%;background:currentColor}
.a-on{color:var(--green)}.a-off{color:var(--red)}.a-never,.a-revoked{color:var(--dim)}
.a-off i,.a-never i{background:transparent !important;box-shadow:inset 0 0 0 1.5px currentColor}
.pop{position:absolute;right:6px;top:calc(100% - 8px);z-index:5;width:360px;padding:14px 16px;border-radius:8px;background:var(--card);border:1px solid var(--line2);box-shadow:var(--shadow);font-size:13px;color:var(--mut)}
.pop::before{content:"";position:absolute;top:-6px;right:30px;width:10px;height:10px;background:var(--card);border-left:1px solid var(--line2);border-top:1px solid var(--line2);transform:rotate(45deg)}
.pop h4{margin:0 0 4px;font:700 14px var(--sans);color:var(--fg)}
.pop p{margin:0 0 12px}
.pop .acts{display:flex;gap:8px;justify-content:flex-end;margin:0}
.drawer{flex:1 1 440px;max-width:520px;border-left:1px solid var(--line2);background:var(--card);padding:28px 28px 40px;display:flex;flex-direction:column;gap:18px}
.dh{display:flex;align-items:center;gap:12px}
.dh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
.dh .x{margin-left:auto}
.steps{display:flex;gap:8px;font-size:12.5px;color:var(--dim)}
.steps span{display:flex;align-items:center;gap:6px}
.steps b{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:50%;font:600 11px var(--mono);box-shadow:inset 0 0 0 1px var(--line2)}
.steps .done{color:var(--green)}.steps .done b{box-shadow:none;background:var(--green);color:var(--bg)}
.steps .now{color:var(--fg)}.steps .now b{box-shadow:none;background:var(--blue);color:var(--onblue)}
.steps .sep{width:16px;height:1px;background:var(--line2)}
.dform{display:flex;flex-direction:column;gap:18px;margin:0}
.field{display:flex;flex-direction:column;gap:6px;font-size:13.5px;color:var(--mut)}
.field input{height:36px;padding:0 12px;border-radius:6px;border:1px solid var(--line2);background:var(--bg);color:var(--fg);font:500 14px var(--mono)}
.field small{color:var(--dim);font-size:12.5px}
.namebox{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-radius:6px;background:var(--bg);font-size:13.5px}
.namebox code{font:500 14px var(--mono);color:var(--fg)}
.notice{display:flex;gap:10px;padding:12px 14px;border-radius:6px;background:var(--orangebg);color:var(--fg);font-size:13.5px}
.notice svg{flex:none;color:var(--orange);margin-top:1px}
.code{border-radius:6px;background:var(--term);overflow:hidden}
.code-h{display:flex;align-items:center;justify-content:space-between;padding:6px 8px 6px 12px;border-bottom:1px solid #1d2230;font:12px var(--mono);color:#8d96a8}
.code pre{margin:0;padding:12px;border:0;color:#cdd3df;font:12px/1.6 var(--mono);overflow-x:auto}
.secret{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:8px 12px;font-size:13px;color:var(--mut)}
.secret code{font:12.5px var(--mono);color:var(--fg);overflow-wrap:anywhere}
.df{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:auto}
.df form{margin:0}
.df > span{margin-right:auto;font-size:13px;color:var(--mut);display:flex;align-items:center;gap:8px}
.df .ok{color:var(--green)}
.spin{width:12px;height:12px;border-radius:50%;box-shadow:inset 0 0 0 2px var(--line2);border-top:2px solid var(--blue);box-sizing:border-box;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Pages that keep the plain markup: service, jobs, job */
h1{font:700 34px/1.1 var(--sans);letter-spacing:-.025em;margin:0 0 18px}
h2{font:700 20px var(--sans);letter-spacing:-.015em;margin:28px 0 8px}
table{width:100%;border-collapse:collapse;margin-bottom:24px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
th{color:var(--mut);font-weight:500}
code,pre{font:13px var(--mono)}
pre{padding:12px;border:1px solid var(--line);border-radius:6px;overflow-x:auto}
.badge{font-size:12px;color:var(--mut);border:1px solid var(--line);padding:0 6px;border-radius:8px}
.status-running{color:var(--green)}
.status-degraded,.status-unknown{color:var(--orange)}
.status-stopped{color:var(--red)}
.error{color:var(--red)}
.warning{color:var(--orange)}
.muted{color:var(--dim)}
.status-job-succeeded{color:var(--green)}
.status-job-queued,.status-job-running{color:var(--orange)}
.status-job-failed{color:var(--red)}
table.details{width:auto}
table.details th{padding-right:24px}
pre.log{white-space:pre-wrap}
@media (max-width:760px){
.side{flex:1 1 100%;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px 16px;padding:12px 16px;border-right:0;border-bottom:1px solid var(--line)}
#snav{order:3;width:100%}
.snav{flex-direction:row;overflow-x:auto}
.snav a{padding:10px 12px}
.snav .n{margin-left:4px}
.mblock{display:none}
.livebox{margin:0 0 0 auto;border:0;padding:0}
.livebox #live-age{display:none}
.main{padding:20px 16px 40px}
.hdr h1{font-size:28px}
.hdr .end,.tools .end{margin-left:0}
.search{flex:1 1 100%}
.btn,button:not([class]){height:44px}
.btn.sm{height:36px}
.row{grid-template-columns:1fr auto;grid-template-areas:"nm st" "ver ver" "fl fl" "mode mode";row-gap:8px;padding:12px 4px}
.nm{grid-area:nm}.stc{grid-area:st}.row .ver{grid-area:ver}.flags{grid-area:fl}.mode{grid-area:mode}
.flags:empty{display:none}
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
.drawer{order:-1;max-width:none;border-left:0;border-bottom:1px solid var(--line2);padding:20px 16px}
.pop{left:0;right:0;width:auto}
table{display:block;overflow-x:auto}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
nav { display: flex; gap: 16px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
nav form { margin-left: auto; }
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
a { color: var(--accent); }
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 500; }
code, pre { font: 13px ui-monospace, monospace; }
pre { padding: 12px; border: 1px solid var(--line); overflow-x: auto; }
.badge { font-size: 12px; color: var(--muted); border: 1px solid var(--line); padding: 0 6px; border-radius: 8px; }
.status-running { color: var(--ok); }
.status-degraded, .status-unknown { color: var(--warn); }
.status-stopped { color: var(--bad); }
.error { color: var(--bad); }
.warning { color: var(--warn); }
form.inline { display: flex; gap: 8px; margin-bottom: 16px; }
@media (max-width: 600px) { table { display: block; overflow-x: auto; } }
.muted { color: var(--muted); }
.status-job-succeeded { color: var(--ok); }
.status-job-queued, .status-job-running { color: var(--warn); }
.status-job-failed { color: var(--bad); }
table.details { width: auto; }
table.details th { padding-right: 24px; }
pre.log { white-space: pre-wrap; }
.badge-update { color: var(--warn); border-color: var(--warn); }
.badge-error { color: var(--bad); border-color: var(--bad); }
form.mode { display: inline; margin-left: 8px; }
form.mode button { font-size: 12px; padding: 0 6px; }
+214
View File
@@ -0,0 +1,214 @@
// Behaviour the server-rendered pages need in the browser: theme, display
// mode, search and machine filter, the Live box, local times, copy buttons and
// the revoke popovers. The pages work without it; htmx and the SSE extension
// do the live updates. Everything stored in the browser is wrapped in
// try/catch because storage can be blocked.
(function () {
'use strict';
var root = document.documentElement;
function load(area, key) {
try { return window[area].getItem(key); } catch (e) { return null; }
}
function save(area, key, value) {
try { window[area].setItem(key, value); } catch (e) { /* storage is blocked */ }
}
function each(selector, fn) {
Array.prototype.forEach.call(document.querySelectorAll(selector), fn);
}
// Theme: the head script set the initial class; the toggle overrides it.
var colorScheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
var themeButton = document.getElementById('theme-toggle');
if (themeButton) {
themeButton.addEventListener('click', function () {
var light = !root.classList.contains('t-light');
root.classList.toggle('t-light', light);
save('localStorage', 'pu-theme', light ? 'light' : 'dark');
});
}
if (colorScheme && colorScheme.addEventListener) {
colorScheme.addEventListener('change', function (e) {
if (!load('localStorage', 'pu-theme')) root.classList.toggle('t-light', e.matches);
});
}
// Display mode: icon, icon + text, or text.
function setDisplay(mode) {
['icon', 'both', 'text'].forEach(function (m) { root.classList.toggle('d-' + m, m === mode); });
each('[data-display]', function (b) { b.setAttribute('aria-pressed', String(b.dataset.display === mode)); });
}
setDisplay(root.classList.contains('d-icon') ? 'icon' : root.classList.contains('d-text') ? 'text' : 'both');
// Search and machine filter. The rows are all in the page; this hides them.
var filter = { q: '', machine: '' };
try {
var saved = JSON.parse(load('sessionStorage', 'pu-filter') || '{}');
if (typeof saved.q === 'string') filter.q = saved.q;
if (typeof saved.machine === 'string') filter.machine = saved.machine;
} catch (e) { /* ignore a broken saved filter */ }
var search = document.getElementById('q');
if (search) search.value = filter.q;
function applyFilter() {
var box = document.getElementById('services');
if (!box) return;
var groups = box.querySelectorAll('.grp');
var known = Array.prototype.some.call(groups, function (g) { return g.dataset.key === filter.machine; });
if (filter.machine && !known) filter.machine = '';
var q = filter.q.trim().toLowerCase();
var any = false;
Array.prototype.forEach.call(groups, function (g) {
var inMachine = !filter.machine || g.dataset.key === filter.machine;
var shown = 0;
Array.prototype.forEach.call(g.querySelectorAll('.row'), function (row) {
var match = inMachine && (!q || (row.dataset.search || '').indexOf(q) !== -1);
row.hidden = !match;
if (match) shown++;
});
g.hidden = shown === 0;
if (shown > 0) any = true;
});
var none = document.getElementById('nomatch');
if (none) none.hidden = any || groups.length === 0;
each('#machines a', function (a) { a.classList.toggle('cur', a.dataset.machine === filter.machine); });
}
function saveFilter() { save('sessionStorage', 'pu-filter', JSON.stringify(filter)); }
if (search) {
search.addEventListener('input', function () { filter.q = search.value; saveFilter(); applyFilter(); });
}
// Live box: counts any event on the stream, including the server's ping.
var live = document.getElementById('live');
var liveAge = document.getElementById('live-age');
var lastEvent = null;
var down = false;
var wired = typeof WeakSet === 'function' ? new WeakSet() : null;
function touch() { lastEvent = Date.now(); }
document.addEventListener('htmx:sseOpen', function (e) {
var source = e.detail && e.detail.source;
touch();
down = false;
if (source && wired && !wired.has(source)) {
wired.add(source);
['ping', 'services', 'agents', 'jobs'].forEach(function (name) { source.addEventListener(name, touch); });
}
});
document.addEventListener('htmx:sseError', function () { down = true; });
function renderLive() {
if (!live || !liveAge) return;
live.classList.toggle('down', down);
if (down) { liveAge.textContent = 'Reconnecting…'; return; }
if (lastEvent === null) { liveAge.textContent = 'connecting…'; return; }
var s = Math.max(0, Math.round((Date.now() - lastEvent) / 1000));
liveAge.textContent = 'last event ' + (s < 60 ? s + ' s ago' : Math.floor(s / 60) + ' min ago');
}
// Times: the server writes <time datetime="..."> with its own clock as
// fallback text; show them in the browser's time zone.
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function pad(n) { return (n < 10 ? '0' : '') + n; }
function localiseTimes() {
each('time[datetime]', function (t) {
var d = new Date(t.getAttribute('datetime'));
if (isNaN(d.getTime())) return;
var text;
if (t.hasAttribute('data-rel')) {
var min = Math.floor((Date.now() - d.getTime()) / 60000);
text = min < 1 ? 'just now' : min < 60 ? min + ' min ago' : min < 1440 ? Math.floor(min / 60) + ' h ago' : Math.floor(min / 1440) + ' d ago';
t.title = d.toLocaleString();
} else {
var clock = pad(d.getHours()) + ':' + pad(d.getMinutes());
text = d.toDateString() === new Date().toDateString() ? clock : months[d.getMonth()] + ' ' + d.getDate() + ' ' + clock;
}
if (t.textContent !== text) t.textContent = text;
});
}
// Copy buttons: data-copy holds the text, data-copy-target the id of the element to copy.
function flash(button, text) {
if (!button.dataset.label) button.dataset.label = button.textContent;
button.textContent = text;
setTimeout(function () { button.textContent = button.dataset.label; }, 2000);
}
function copyFallback(button, text) {
var target = button.dataset.copyTarget ? document.getElementById(button.dataset.copyTarget) : null;
if (target) {
var range = document.createRange();
range.selectNodeContents(target);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
flash(button, 'Press Ctrl+C');
return;
}
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { /* not allowed */ }
document.body.removeChild(area);
flash(button, ok ? 'Copied' : 'Copy failed');
}
// Revoke popovers are <details>. A swap of the agent list would close them, so reopen those that were open.
var openRevokes = [];
document.addEventListener('click', function (e) {
var display = e.target.closest('[data-display]');
if (display) {
setDisplay(display.dataset.display);
save('localStorage', 'pu-display', display.dataset.display);
return;
}
var machine = e.target.closest('#machines a');
if (machine) {
e.preventDefault();
filter.machine = filter.machine === machine.dataset.machine ? '' : machine.dataset.machine;
saveFilter();
applyFilter();
return;
}
var close = e.target.closest('[data-close]');
if (close) {
var details = close.closest('details');
if (details) details.open = false;
return;
}
var copy = e.target.closest('[data-copy], [data-copy-target]');
if (copy) {
var text = copy.dataset.copy;
if (text === undefined) {
var el = document.getElementById(copy.dataset.copyTarget);
text = el ? el.textContent : '';
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { flash(copy, 'Copied'); }, function () { copyFallback(copy, text); });
} else {
copyFallback(copy, text);
}
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') each('details.rv[open]', function (d) { d.open = false; });
});
document.addEventListener('htmx:beforeSwap', function () {
openRevokes = [];
each('details.rv[open]', function (d) { openRevokes.push(d.dataset.id); });
});
document.addEventListener('htmx:afterSwap', function () {
each('details.rv', function (d) { if (openRevokes.indexOf(d.dataset.id) !== -1) d.open = true; });
});
document.addEventListener('htmx:afterSettle', function () { applyFilter(); localiseTimes(); });
applyFilter();
localiseTimes();
renderLive();
setInterval(renderLive, 1000);
setInterval(localiseTimes, 15000);
})();
@@ -1,6 +0,0 @@
{{define "content"}}
<h1>Agent "{{.Created.Name}}" created</h1>
<p class="warning">The token is shown only once. Save this as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</p>
<pre>{{.Snippet}}</pre>
<p><a href="/agents">Back to agents</a></p>
{{end}}
+90 -28
View File
@@ -1,38 +1,100 @@
{{define "content"}}
<h1>Agents</h1>
<form method="post" action="/agents" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<label>Name <input name="name" required placeholder="node-2"></label>
<button type="submit">Add agent</button>
</form>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="hdr ruled">
<h1>Agents</h1>
<span class="sub">{{.Nav.AgentsTotal}} {{if eq .Nav.AgentsTotal 1}}machine{{else}}machines{{end}}{{if .MasterVersion}} · master {{.MasterVersion}}{{end}}</span>
<div class="end"><a class="btn pri" href="/agents?add=1">Add agent</a></div>
</div>
<div id="agents" hx-get="/fragments/agents" hx-trigger="sse:agents" hx-swap="innerHTML">
{{template "agents" .}}
</div>
{{end}}
{{define "agents"}}
<table>
<thead><tr><th>Name</th><th>Hostname</th><th>State</th><th>Version</th><th>Swarm manager</th><th>Last seen</th><th></th></tr></thead>
<tbody>
{{range .Agents}}
<tr>
<td>{{.Name}}</td>
<td>{{.Hostname}}</td>
<td>{{if .RevokedAt}}<span class="status status-stopped">revoked</span>{{else if .Online}}<span class="status status-running">online</span>{{else}}<span class="status status-degraded">offline</span>{{end}}</td>
<td>{{.AgentVersion}}</td>
<td>{{if .SwarmManager}}yes{{end}}</td>
<td>{{with .LastSeenAt}}{{.Format "2006-01-02 15:04"}}{{end}}</td>
<td>{{if not .RevokedAt}}
<form method="post" action="/agents/{{.ID}}/revoke" onsubmit="return confirm('Revoke {{.Name}}? It is disconnected immediately and must be added again to come back.')">
{{range .Agents}}
<div class="agent">
<div class="id"><b>{{.Name}}</b><span>{{.Hostname}}</span></div>
<div class="stc"><span class="st {{.StatusClass}}"><i></i>{{.Status}}</span></div>
<div class="ver{{if .VersionOld}} old{{end}}"><span class="k">Agent</span>{{.AgentVersion}}</div>
<div class="mgr"><span class="k">Swarm</span>{{.Role}}</div>
<div class="last"><span class="k">Last seen</span>{{if .Online}}now{{else}}{{with .LastSeenAt}}<time datetime="{{.Format "2006-01-02T15:04:05Z07:00"}}" data-rel>{{.Format "2006-01-02 15:04"}}</time>{{else}}—{{end}}{{end}}</div>
<div class="act">{{if not .RevokedAt}}
<details class="rv" data-id="{{.ID}}">
<summary class="btn sm red">Revoke</summary>
<div class="pop" role="dialog" aria-label="Confirm revoke">
<h4>Revoke {{.Name}}?</h4>
<p>Its token stops working immediately. Services on {{.Name}} keep running, but drop out of package-updater.</p>
<form method="post" action="/agents/{{.ID}}/revoke" class="acts">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<button type="submit">Revoke</button>
<button type="button" class="btn sm quiet" data-close>Cancel</button>
<button type="submit" class="btn sm redfill">Revoke</button>
</form>
{{end}}</td>
</tr>
{{else}}
<tr><td colspan="7">No agents yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</details>
{{end}}</div>
</div>
{{else}}
<p class="nomatch">No agents yet.</p>
{{end}}
{{end}}
{{/* The "add agent" panel beside the list. Step 1 asks for a name, step 2 shows the one-time token, step 3 waits for the agent to connect. */}}
{{define "drawer"}}
{{if .AddStep}}
<section class="drawer" aria-label="Add agent">
<div class="dh">
<h2>Add agent</h2>
<a class="btn sm quiet x" href="/agents" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8"></path></svg>
</a>
</div>
{{if eq .AddStep 1}}
<div class="steps" id="add-steps">{{template "steps" 1}}</div>
<form method="post" action="/agents" class="dform">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<label class="field">Name
<input name="name" required autofocus autocomplete="off" placeholder="node-2" value="{{.FormName}}">
<small>Lowercase letters, digits and dashes. Use the machine's name.</small>
</label>
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
<div class="df">
<a class="btn quiet" href="/agents">Cancel</a>
<button type="submit" class="btn pri">Create agent</button>
</div>
</form>
{{else}}
<div class="steps" id="add-steps">{{template "steps" 2}}</div>
<div class="namebox"><span>Name</span><code>{{.Created.Name}}</code></div>
<div class="notice" role="note">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M8 1.8L15 14H1L8 1.8z"></path><path d="M8 6.5v3.5M8 12v.01"></path></svg>
<span><b>This token is shown only once.</b> Save the snippet as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</span>
</div>
<div class="code">
<div class="code-h"><span>docker-compose.yml</span><button type="button" class="btn sm" data-copy-target="snippet">Copy</button></div>
<pre id="snippet">{{.Snippet}}</pre>
</div>
<div class="secret">
<span>Token</span><code>{{middle .Token}}</code><button type="button" class="btn sm" data-copy="{{.Token}}">Copy</button>
<span>Master key</span><code>{{middle .MasterKey}}</code><button type="button" class="btn sm" data-copy="{{.MasterKey}}">Copy</button>
</div>
<div class="df" id="connect" hx-get="/fragments/agents/{{.Created.ID}}/connect" hx-trigger="load, sse:agents" hx-swap="innerHTML">
{{template "connect" .}}
</div>
{{end}}
</section>
{{end}}
{{end}}
{{/* step is the one in progress (1 to 3); 4 marks all three as done. */}}
{{define "steps"}}<span class="{{if gt . 1}}done{{else}}now{{end}}"><b>1</b>Name</span><span class="sep"></span><span class="{{if gt . 2}}done{{else if eq . 2}}now{{end}}"><b>2</b>Deploy</span><span class="sep"></span><span class="{{if gt . 3}}done{{else if eq . 3}}now{{end}}"><b>3</b>Connect</span>{{end}}
{{/* Step 3. Until the agent has connected, Cancel revokes the token that was just created. */}}
{{define "connect"}}{{if .Connected}}<span class="ok">Connected · {{.Created.Hostname}} · {{.Created.AgentVersion}}</span>
<a class="btn pri" href="/agents">Done</a>
<div id="add-steps" hx-swap-oob="innerHTML">{{template "steps" 4}}</div>
{{else}}<span><span class="spin" aria-hidden="true"></span>Waiting for {{.Created.Name}}…</span>
<form method="post" action="/agents/{{.Created.ID}}/revoke">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit" class="btn quiet">Cancel</button>
</form>
<a class="btn pri" href="/agents">Done</a>
{{end}}{{end}}
+50 -11
View File
@@ -1,23 +1,62 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<html lang="en" class="app">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>{{.Title}} · package-updater</title>
<script>
// Sets the theme and the display mode before first paint. app.js changes them later.
(function () {
var root = document.documentElement, theme = null, display = null;
try { theme = localStorage.getItem('pu-theme'); display = localStorage.getItem('pu-display'); } catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
if (theme === 'light') root.classList.add('t-light');
root.classList.add(display === 'icon' || display === 'text' ? 'd-' + display : 'd-both');
})();
</script>
<link rel="preload" href="/static/schibsted-grotesk-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js"></script>
<script src="/static/htmx-ext-sse.js"></script>
<script src="/static/app.js" defer></script>
</head>
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}' hx-ext="sse" sse-connect="/events">
<nav>
<a href="/">Services</a>
<a href="/jobs">Jobs</a>
<a href="/agents">Agents</a>
<form method="post" action="/auth/logout">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit">Log out</button>
</form>
</nav>
<main>{{template "content" .}}</main>
<div class="shell">
<aside class="side">
<div class="brand">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M3 6h14M3 10h9M3 14h6"></path><path d="M15 16V10m-2.5 2.5L15 10l2.5 2.5"></path></svg>
package-updater
</div>
<div id="snav" hx-get="/fragments/nav?section={{.Nav.Section}}" hx-trigger="sse:services, sse:agents" hx-swap="innerHTML">{{template "nav" .}}</div>
{{block "machines" .}}{{end}}
<div class="livebox" id="live">
<b><span class="pulse" aria-hidden="true"></span>Live</b>
<span id="live-age">connecting…</span>
</div>
<div class="sfoot">
<button type="button" class="btn sm quiet" id="theme-toggle">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="7" cy="7" r="5"></circle><path d="M7 2v10" ></path></svg>
Theme
</button>
<form method="post" action="/auth/logout">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit" class="btn sm quiet">Log out</button>
</form>
</div>
</aside>
<div class="work">
<main class="main">{{template "content" .}}</main>
{{block "drawer" .}}{{end}}
</div>
</div>
</body>
</html>{{end}}
{{define "nav"}}<nav class="snav" aria-label="Main">
<a href="/"{{if eq .Nav.Section "services"}} class="cur" aria-current="page"{{end}}>Services <span class="n">{{.Nav.Services}}</span></a>
<a href="/jobs"{{if eq .Nav.Section "jobs"}} class="cur" aria-current="page"{{end}}>Jobs</a>
<a href="/agents"{{if eq .Nav.Section "agents"}} class="cur" aria-current="page"{{end}}>Agents <span class="n">{{.Nav.AgentsOnline}}/{{.Nav.AgentsTotal}}</span></a>
</nav>{{end}}
+93 -28
View File
@@ -1,38 +1,103 @@
{{define "content"}}
<h1>Services</h1>
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services" hx-swap="innerHTML">
<div class="hdr">
<h1>Services</h1>
</div>
<div class="tools">
<label class="search">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5"></circle><path d="M10.3 10.3L14 14"></path></svg>
<input type="search" id="q" placeholder="Search services" aria-label="Search services" autocomplete="off">
</label>
<div class="end">
<span class="lbl" id="showlbl">Show</span>
<div class="seg" role="group" aria-labelledby="showlbl">
<button type="button" data-display="icon" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2" y="2" width="10" height="10" rx="2.5"></rect></svg>
Icon
</button>
<button type="button" data-display="both" aria-pressed="true">
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="1" y="3" width="8" height="8" rx="2"></rect><path d="M12 7h5"></path></svg>
Icon + text
</button>
<button type="button" data-display="text" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M2 4.5h10M2 9.5h7"></path></svg>
Text
</button>
</div>
</div>
</div>
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services, sse:agents" hx-swap="innerHTML">
{{template "services" .}}
</div>
{{end}}
{{define "services"}}
{{/* The machine filter in the sidebar. The page defines it; the services fragment replaces its links out of band. */}}
{{define "machines"}}
<div class="mblock">
<div class="slabel">Machines</div>
<nav class="mlist" id="machines" aria-label="Filter by machine">{{template "machine-links" .}}</nav>
</div>
{{end}}
{{define "machine-links"}}
<a class="cur" href="/" data-machine="">All <span class="n">{{.ServiceCount}}</span></a>
{{range .Groups}}
<section>
<h2>{{.Title}}</h2>
<table>
<thead><tr><th>Service</th><th>Image</th><th>Status</th><th>Mode</th></tr></thead>
<tbody>
{{range .Services}}
<tr>
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}{{if .AvailableRef}} <span class="badge badge-update" title="{{.AvailableRef}}">update available</span>{{end}}{{if followsNoTag .Service}} <span class="badge">follows no tag</span>{{end}}{{if .LastPollError}} <span class="badge badge-error" title="{{.LastPollError}}">poll failed</span>{{end}}</td>
<td><code>{{shortRef .ActualRef}}</code></td>
<td class="status status-{{.Status}}">{{.Status}}</td>
<td>{{.UpdateMode}}
<form method="post" action="/services/{{.ID}}/mode" class="mode">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="return" value="overview">
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
<button type="submit">Make manual</button>
{{else}}<input type="hidden" name="mode" value="auto">
<button type="submit"{{if .AutoBlocked}} disabled{{end}}>Make auto</button>{{if .AutoBlocked}} <span class="muted">{{.AutoBlocked}}</span>{{end}}{{end}}
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
<a href="/"{{if .Sub}} class="sub"{{end}} data-machine="{{.Key}}">{{if .Cluster}}<span class="sw-icon" aria-hidden="true"></span>{{else}}<span class="mdot{{if .Offline}} off{{end}}" aria-hidden="true"></span>{{end}}{{.Title}} <span class="n">{{len .Services}}</span></a>
{{end}}
{{end}}
{{/* What /fragments/services returns: the groups, and the machine filter swapped in out of band. */}}
{{define "services-fragment"}}
{{template "services" .}}
<div id="machines" hx-swap-oob="innerHTML">{{template "machine-links" .}}</div>
{{end}}
{{define "services"}}
{{if .Sprite}}<svg class="sprite" aria-hidden="true">{{range .Sprite}}<symbol id="si-{{.Slug}}" viewBox="0 0 24 24"><path fill="currentColor" d="{{.Path}}"/></symbol>{{end}}</svg>{{end}}
{{range .Groups}}
<section class="grp{{if .Offline}} offline{{end}}" data-key="{{.Key}}" aria-label="{{.Title}}">
<div class="gh">
<h2{{if .TitleHint}} title="{{.TitleHint}}"{{end}}>{{.Title}}</h2>
<span class="kind">{{.Kind}}</span>
{{if .Meta}}<span class="meta">{{.Meta}}</span>{{end}}
{{if .Offline}}<span class="warn">{{.OfflineLabel}}{{with .OfflineSince}} since <time datetime="{{.Format "2006-01-02T15:04:05Z07:00"}}">{{.Format "15:04"}}</time>{{end}} · showing last known state</span>{{end}}
<span class="count">{{len .Services}}</span>
</div>
{{range .Services}}
<div class="row" data-search="{{.Search}}">
<div class="nm">
<a href="/services/{{.ID}}" title="{{.DisplayName}}" aria-label="{{.DisplayName}}">
{{if .Icon}}<span class="ico" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 24 24"><use href="#si-{{.Icon}}"></use></svg></span>{{else}}<span class="ltr {{.LetterClass}}" aria-hidden="true">{{.Letter}}</span>{{end}}
<span class="nmt">{{.Name}}</span>
</a>
{{if .Stack}}<span class="stk">{{.Stack}}</span>{{end}}
</div>
<div class="ver" title="{{.ActualRef}}"><span class="r">{{.Repo}}</span><span class="t"><b>{{.Tag}}</b>{{.Digest}}</span></div>
<div class="stc">
<span class="st {{.StatusClass}}"><svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" stroke="currentColor" stroke-width="1.5"></circle></svg>{{.StatusLabel}}</span>
</div>
<div class="flags">
{{if .AvailableRef}}<span class="tag f-up" title="{{.AvailableRef}}">Update available<em>{{.AvailableTag}}</em></span>{{end}}
{{if followsNoTag .Service}}<span class="tag f-notag">Follows no tag</span>{{end}}
{{if .LastPollError}}<span class="tag f-err" title="{{.LastPollError}}">Poll failed</span>{{end}}
{{if .Self}}<span class="tag f-out">Managed outside</span>{{end}}
</div>
<div class="mode">
{{if .Self}}<span class="na">n/a</span>
{{else}}<form method="post" action="/services/{{.ID}}/mode">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="return" value="overview">
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
<button type="submit" class="sw" role="switch" aria-checked="true"><i></i>Auto</button>
{{else}}<input type="hidden" name="mode" value="auto">
<button type="submit" class="sw" role="switch" aria-checked="false"{{if .AutoBlocked}} disabled title="{{.AutoBlocked}}"{{end}}><i></i>Manual</button>
{{if .AutoBlocked}}<span class="na">{{.AutoBlocked}}</span>{{end}}{{end}}
</form>{{end}}
</div>
</div>
{{end}}
</section>
{{else}}
<p>No services reported yet. <a href="/agents">Add an agent</a> to get started.</p>
<p>No services reported yet. <a href="/agents?add=1">Add an agent</a> to get started.</p>
{{end}}
<p class="nomatch" id="nomatch" hidden>No services match.</p>
{{end}}
+3 -3
View File
@@ -214,8 +214,8 @@ func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) {
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Equal(t, 1, strings.Count(body, "follows no tag"), "only the digest-only Swarm service: "+path)
assert.Contains(t, body, `class="badge badge-error" title="head x: &#34;boom&#34; &lt;script&gt;">poll failed</span>`, path)
assert.Equal(t, 1, strings.Count(body, "poll failed"), path)
assert.Equal(t, 1, strings.Count(body, "Follows no tag"), "only the digest-only Swarm service: "+path)
assert.Contains(t, body, `class="tag f-err" title="head x: &#34;boom&#34; &lt;script&gt;">Poll failed</span>`, path)
assert.Equal(t, 1, strings.Count(body, "Poll failed"), path)
}
}
+245 -25
View File
@@ -1,58 +1,239 @@
package web
import (
"cmp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/shcizo/package-updater/internal/imageref"
"github.com/shcizo/package-updater/internal/master/jobs"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/poller"
"github.com/shcizo/package-updater/internal/master/web/icons"
"github.com/shcizo/package-updater/internal/protocol"
)
// serviceGroup is one block of the overview: a machine, a Swarm cluster or
// the services whose agent is unknown.
type serviceGroup struct {
Title string
Services []serviceRow
Key string // "agent-<id>", "swarm-<cluster id>" or "unknown"; the machine filter matches on it
Title string
TitleHint string // hover text of the title; the full cluster id
Kind string // "Machine" or "Swarm cluster"
Meta string // hostname, or the cluster's managers
Cluster bool
Sub bool // a machine that belongs to the cluster shown above it
// Offline says the agent (or, for a cluster, every manager) is not
// connected, so the rows show the last known state.
Offline bool
OfflineLabel string // what the banner says before "since"
OfflineSince *time.Time // when the agent was last seen; nil if unknown
Services []serviceRow
}
// serviceRow is a service as shown in the overview table.
// serviceRow is a service as shown in the overview.
type serviceRow struct {
model.Service
AutoBlocked string // why the service cannot be set to auto; "" when it can
Name string // the service's own name, without its stack
Stack string // the Compose project; "" for Swarm and plain containers
Repo string // image repository without the registry host
Tag string // ":tag", or ""
Digest string // "@sha256:<12 hex>…" when the image has no tag, else ""
AvailableTag string // the tag of AvailableRef, or ""
Icon string // Simple Icons slug; "" when there is no icon
Letter string // shown instead of an icon
LetterClass string // lc-0 .. lc-3
StatusLabel string
StatusClass string
Search string // what the search box matches against, lowercased
}
// groupServices groups machine-bound services per agent and Swarm services
// in one group, keeping the store's order (machines first, Swarm last).
// autoBlocked is called for every service and returns the reason it cannot be
// set to auto, or "" when it can.
func groupServices(services []model.Service, autoBlocked func(model.Service) string) []serviceGroup {
var groups []serviceGroup
index := map[string]int{}
// newServiceRow fills the display fields of a row.
func newServiceRow(s model.Service, autoBlocked string) serviceRow {
r := serviceRow{Service: s, AutoBlocked: autoBlocked}
switch s.Kind {
case protocol.KindCompose:
r.Name, r.Stack = s.ComposeService, s.ComposeProject
case protocol.KindSwarm:
r.Name = s.SwarmService
default:
r.Name = s.ContainerName
}
r.Repo, r.Tag, r.Digest = imageParts(s.ActualRef)
r.AvailableTag = imageref.Parse(s.AvailableRef).Tag
r.Icon = icons.ForImage(s.ActualRef)
r.Letter = "?"
if first, _ := utf8.DecodeRuneInString(r.Name); first != utf8.RuneError {
r.Letter = strings.ToUpper(string(first))
}
r.LetterClass = letterClass(r.Name)
switch s.Status {
case "running":
r.StatusLabel, r.StatusClass = "Running", "s-running"
case "degraded":
r.StatusLabel, r.StatusClass = "Degraded", "s-degraded"
case "stopped":
r.StatusLabel, r.StatusClass = "Stopped", "s-stopped"
default:
r.StatusLabel, r.StatusClass = "Unknown", "s-unknown"
}
r.Search = strings.ToLower(strings.Join([]string{r.Name, r.Stack, r.Repo, strings.TrimPrefix(r.Tag, ":"), s.AgentName}, " "))
return r
}
// imageParts splits an image reference for display: the repository without
// its registry host, ":tag", and a shortened digest that is only given when
// there is no tag.
func imageParts(ref string) (repo, tag, digest string) {
r := imageref.Parse(ref)
repo = r.Repo
if host, rest, ok := strings.Cut(repo, "/"); ok && (strings.ContainsAny(host, ".:") || host == "localhost") {
repo = rest
}
if r.Tag != "" {
return repo, ":" + r.Tag, ""
}
if r.Digest != "" {
return repo, "", "@" + shortDigest(r.Digest)
}
return repo, "", ""
}
// shortDigest shortens "sha256:<hex>" to 12 hex characters and an ellipsis.
func shortDigest(d string) string {
if hex, ok := strings.CutPrefix(d, "sha256:"); ok && len(hex) > 12 {
return "sha256:" + hex[:12] + "…"
}
return d
}
// letterClass picks one of four colours from the name, so a service keeps
// its colour.
func letterClass(name string) string {
sum := 0
for i := 0; i < len(name); i++ {
sum += int(name[i])
}
return "lc-" + strconv.Itoa(sum%4)
}
// groupServices groups the services into one block per Swarm cluster and per
// machine. A cluster comes first, followed by the machines that belong to it;
// the remaining machines follow by name and the services whose agent is
// unknown come last. Agents without services get no block. online says
// whether an agent is connected; autoBlocked returns the reason a service
// cannot be set to auto, or "".
func groupServices(services []model.Service, agents []model.Agent, online func(int64) bool, autoBlocked func(model.Service) string) []serviceGroup {
agentByID := map[int64]model.Agent{}
for _, a := range agents {
agentByID[a.ID] = a
}
var clusterIDs []string
clusterRows := map[string][]serviceRow{}
clusterRowAgents := map[string]map[int64]bool{} // agents named by a cluster's rows
machineRows := map[int64][]serviceRow{}
var unknownRows []serviceRow
for _, s := range services {
title := s.AgentName
if s.Kind == protocol.KindSwarm {
title = "Swarm"
} else if title == "" {
title = "Unknown agent"
row := newServiceRow(s, autoBlocked(s))
_, known := agentByID[derefID(s.AgentID)]
switch {
case s.Kind == protocol.KindSwarm:
if _, seen := clusterRows[s.SwarmClusterID]; !seen {
clusterIDs = append(clusterIDs, s.SwarmClusterID)
clusterRowAgents[s.SwarmClusterID] = map[int64]bool{}
}
clusterRows[s.SwarmClusterID] = append(clusterRows[s.SwarmClusterID], row)
if s.AgentID != nil {
clusterRowAgents[s.SwarmClusterID][*s.AgentID] = true
}
case s.AgentID != nil && known:
machineRows[*s.AgentID] = append(machineRows[*s.AgentID], row)
default:
unknownRows = append(unknownRows, row)
}
i, ok := index[title]
if !ok {
i = len(groups)
index[title] = i
groups = append(groups, serviceGroup{Title: title})
}
machineIDs := make([]int64, 0, len(machineRows))
for id := range machineRows {
machineIDs = append(machineIDs, id)
}
slices.SortFunc(machineIDs, func(a, b int64) int {
return cmp.Or(cmp.Compare(agentByID[a].Name, agentByID[b].Name), cmp.Compare(a, b))
})
machine := func(id int64, sub bool) serviceGroup {
a := agentByID[id]
return serviceGroup{
Key: "agent-" + strconv.FormatInt(id, 10), Title: a.Name, Kind: "Machine", Meta: a.Hostname, Sub: sub,
Offline: !online(id), OfflineLabel: "Agent offline", OfflineSince: a.LastSeenAt, Services: machineRows[id],
}
groups[i].Services = append(groups[i].Services, serviceRow{Service: s, AutoBlocked: autoBlocked(s)})
}
var groups []serviceGroup
placed := map[int64]bool{}
for _, cid := range clusterIDs {
title := "Swarm"
if len(clusterIDs) > 1 {
title += " " + cid[:min(len(cid), 8)]
}
g := serviceGroup{
Key: "swarm-" + cid, Title: title, TitleHint: cid, Kind: "Swarm cluster", Cluster: true,
Offline: true, OfflineLabel: "No manager online", Services: clusterRows[cid],
}
var names []string
for _, a := range agents {
isManager := a.SwarmManager && a.SwarmClusterID == cid
if a.RevokedAt != nil || !(isManager || clusterRowAgents[cid][a.ID]) {
continue
}
names = append(names, a.Name)
if online(a.ID) {
g.Offline = false
}
if a.LastSeenAt != nil && (g.OfflineSince == nil || a.LastSeenAt.After(*g.OfflineSince)) {
g.OfflineSince = a.LastSeenAt
}
}
slices.Sort(names)
if len(names) > 0 {
g.Meta = "managers " + strings.Join(names, ", ")
}
groups = append(groups, g)
for _, id := range machineIDs {
if cid != "" && agentByID[id].SwarmClusterID == cid && !placed[id] {
placed[id] = true
groups = append(groups, machine(id, true))
}
}
}
for _, id := range machineIDs {
if !placed[id] {
groups = append(groups, machine(id, false))
}
}
if len(unknownRows) > 0 {
groups = append(groups, serviceGroup{
Key: "unknown", Title: "Unknown agent", Kind: "Machine", Offline: true, OfflineLabel: "Agent offline", Services: unknownRows,
})
}
return groups
}
func derefID(id *int64) int64 {
if id == nil {
return 0
}
return *id
}
// shortRef shortens a digest in an image reference to 12 hex characters.
func shortRef(ref string) string {
r := imageref.Parse(ref)
if hex, ok := strings.CutPrefix(r.Digest, "sha256:"); ok && len(hex) > 12 {
r.Digest = "sha256:" + hex[:12] + "…"
}
r.Digest = shortDigest(r.Digest)
return r.String()
}
@@ -101,9 +282,48 @@ func shortDuration(d time.Duration) string {
return s
}
// agentRow is an agent as shown on the agents page.
type agentRow struct {
model.Agent
Online bool
Online bool
Status string // Online, Offline, Never connected or Revoked
StatusClass string
Role string // Manager, Worker or "—"
VersionOld bool // the agent runs another version than the master
}
// newAgentRow fills the display fields of a row. masterVersion is the
// master's build version; "dev" and "" never mark an agent as old.
func newAgentRow(a model.Agent, online bool, masterVersion string) agentRow {
r := agentRow{Agent: a, Online: online}
switch {
case a.RevokedAt != nil:
r.Status, r.StatusClass = "Revoked", "a-revoked"
case online:
r.Status, r.StatusClass = "Online", "a-on"
case a.LastSeenAt == nil:
r.Status, r.StatusClass = "Never connected", "a-never"
default:
r.Status, r.StatusClass = "Offline", "a-off"
}
switch {
case a.SwarmManager:
r.Role = "Manager"
case a.SwarmClusterID != "":
r.Role = "Worker"
default:
r.Role = "—"
}
r.VersionOld = a.AgentVersion != "" && masterVersion != "" && masterVersion != "dev" && a.AgentVersion != masterVersion
return r
}
// middle shortens a long secret for display: its first 8 and last 4 characters.
func middle(s string) string {
if len(s) <= 14 {
return s
}
return s[:8] + "…" + s[len(s)-4:]
}
// jobDuration is how long a job ran, or has been running; "" before it starts.
+213
View File
@@ -0,0 +1,213 @@
package web
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/protocol"
)
func TestImageParts(t *testing.T) {
cases := []struct {
ref, repo, tag, digest string
}{
{"postgres:17.10", "postgres", ":17.10", ""},
{"postgres", "postgres", "", ""},
{"gitea/gitea:1.27.3", "gitea/gitea", ":1.27.3", ""},
{"gitea.shcizo.se/shcizo/slusk:v0.8.1", "shcizo/slusk", ":v0.8.1", ""},
{"localhost:5000/foo:v1", "foo", ":v1", ""},
{"localhost/foo", "foo", "", ""},
{"lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", "linuxserver/jellyfin", "", "@sha256:0f42497a69fa…"},
{"r/x:1.0@sha256:0f42497a69fa0441", "r/x", ":1.0", ""}, // the digest is hidden when there is a tag
{"r/x@sha256:abc", "r/x", "", "@sha256:abc"},
}
for _, c := range cases {
t.Run(c.ref, func(t *testing.T) {
repo, tag, digest := imageParts(c.ref)
assert.Equal(t, c.repo, repo)
assert.Equal(t, c.tag, tag)
assert.Equal(t, c.digest, digest)
})
}
}
func TestLetterClass(t *testing.T) {
assert.Equal(t, "lc-0", letterClass(""))
assert.Equal(t, "lc-1", letterClass("a"), "97 mod 4")
assert.Equal(t, letterClass("gitea"), letterClass("gitea"), "a name always gets the same colour")
for _, name := range []string{"gitea", "authentik-server", "ø", "n8n"} {
assert.Regexp(t, `^lc-[0-3]$`, letterClass(name))
}
}
func TestNewAgentRow(t *testing.T) {
seen := ptr(time.Now())
cases := []struct {
name string
agent model.Agent
online bool
masterVersion string
status, role string
old bool
}{
{"online manager", model.Agent{SwarmManager: true, AgentVersion: "v1"}, true, "v1", "Online", "Manager", false},
{"offline worker on another version", model.Agent{SwarmClusterID: "c", AgentVersion: "v0", LastSeenAt: seen}, false, "v1", "Offline", "Worker", true},
{"never connected", model.Agent{}, false, "v1", "Never connected", "—", false},
{"revoked wins over online", model.Agent{RevokedAt: seen, LastSeenAt: seen}, true, "v1", "Revoked", "—", false},
{"dev master marks nothing", model.Agent{AgentVersion: "v0"}, true, "dev", "Online", "—", false},
{"agent without version", model.Agent{}, true, "v1", "Online", "—", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
row := newAgentRow(c.agent, c.online, c.masterVersion)
assert.Equal(t, c.status, row.Status)
assert.Equal(t, c.role, row.Role)
assert.Equal(t, c.old, row.VersionOld)
})
}
}
func TestMiddle(t *testing.T) {
assert.Equal(t, "short", middle("short"))
assert.Equal(t, "pua_7Hq2…Ge5A", middle("pua_7Hq2mX9vKc4LwT8nRb3ZyF6dJp1sGe5A"))
}
func TestGroupServices(t *testing.T) {
at := func(h, m int) *time.Time { return ptr(time.Date(2026, 10, 12, h, m, 0, 0, time.UTC)) }
id := func(n int64) *int64 { return &n }
compose := func(sid int64, agent *int64, name string) model.Service {
return model.Service{ID: sid, Kind: protocol.KindCompose, AgentID: agent, ComposeProject: "p", ComposeService: name, ActualRef: "r/" + name + ":1"}
}
swarm := func(sid int64, cluster string, agent *int64) model.Service {
return model.Service{ID: sid, Kind: protocol.KindSwarm, SwarmClusterID: cluster, AgentID: agent, SwarmService: "s" + cluster}
}
noBlock := func(model.Service) string { return "" }
onlineSet := func(ids ...int64) func(int64) bool {
return func(a int64) bool {
for _, i := range ids {
if i == a {
return true
}
}
return false
}
}
titles := func(groups []serviceGroup) []string {
var out []string
for _, g := range groups {
out = append(out, g.Title)
}
return out
}
t.Run("empty", func(t *testing.T) {
assert.Empty(t, groupServices(nil, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(), noBlock))
})
t.Run("agents without services get no group", func(t *testing.T) {
groups := groupServices([]model.Service{compose(1, id(1), "web")},
[]model.Agent{{ID: 1, Name: "a"}, {ID: 2, Name: "idle"}}, onlineSet(1, 2), noBlock)
assert.Equal(t, []string{"a"}, titles(groups))
assert.Equal(t, "agent-1", groups[0].Key)
assert.Equal(t, "Machine", groups[0].Kind)
assert.False(t, groups[0].Offline)
})
t.Run("services without a known agent are offline and last", func(t *testing.T) {
groups := groupServices(
[]model.Service{compose(1, nil, "orphan"), compose(2, id(7), "ghost"), compose(3, id(1), "web")},
[]model.Agent{{ID: 1, Name: "a"}}, onlineSet(1), noBlock)
assert.Equal(t, []string{"a", "Unknown agent"}, titles(groups))
unknown := groups[1]
assert.Equal(t, "unknown", unknown.Key)
assert.True(t, unknown.Offline)
assert.Nil(t, unknown.OfflineSince)
assert.Len(t, unknown.Services, 2)
})
t.Run("an offline agent says since when", func(t *testing.T) {
groups := groupServices([]model.Service{compose(1, id(1), "web")},
[]model.Agent{{ID: 1, Name: "a", Hostname: "a.lan", LastSeenAt: at(13, 51)}}, onlineSet(), noBlock)
require.Len(t, groups, 1)
assert.True(t, groups[0].Offline)
assert.Equal(t, "Agent offline", groups[0].OfflineLabel)
assert.Equal(t, at(13, 51), groups[0].OfflineSince)
assert.Equal(t, "a.lan", groups[0].Meta)
})
t.Run("a cluster is online when any manager is", func(t *testing.T) {
agents := []model.Agent{
{ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)},
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)},
}
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(2), noBlock)
require.Len(t, groups, 1)
assert.False(t, groups[0].Offline)
assert.Equal(t, "Swarm", groups[0].Title)
assert.Equal(t, "c1", groups[0].TitleHint)
assert.Equal(t, "Swarm cluster", groups[0].Kind)
assert.Equal(t, "managers m1, m2", groups[0].Meta)
assert.Equal(t, "swarm-c1", groups[0].Key)
})
t.Run("a cluster with no manager online has been offline since the latest manager was seen", func(t *testing.T) {
agents := []model.Agent{
{ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)},
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)},
}
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(), noBlock)
assert.True(t, groups[0].Offline)
assert.Equal(t, "No manager online", groups[0].OfflineLabel)
assert.Equal(t, at(11, 0), groups[0].OfflineSince)
})
t.Run("a revoked manager does not count", func(t *testing.T) {
agents := []model.Agent{
{ID: 1, Name: "gone", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(12, 0), RevokedAt: at(12, 5)},
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(9, 0)},
}
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(1), noBlock)
assert.True(t, groups[0].Offline, "the revoked manager is not connected for this purpose")
assert.Equal(t, "managers m2", groups[0].Meta)
assert.Equal(t, at(9, 0), groups[0].OfflineSince)
})
t.Run("managers are found through the rows when no agent reports the cluster", func(t *testing.T) {
agents := []model.Agent{{ID: 4, Name: "reporter", LastSeenAt: at(8, 0)}}
groups := groupServices([]model.Service{swarm(1, "c1", id(4))}, agents, onlineSet(4), noBlock)
assert.False(t, groups[0].Offline)
assert.Equal(t, "managers reporter", groups[0].Meta)
})
t.Run("order is cluster, its machines, other machines by name, unknown", func(t *testing.T) {
agents := []model.Agent{
{ID: 1, Name: "zeta"},
{ID: 2, Name: "mgr", SwarmManager: true, SwarmClusterID: "c1"},
{ID: 3, Name: "alpha"},
{ID: 4, Name: "mgr2", SwarmManager: true, SwarmClusterID: "c1"},
}
services := []model.Service{
compose(1, id(1), "a"), compose(2, id(2), "b"), compose(3, id(3), "c"),
compose(4, id(4), "d"), compose(5, nil, "e"), swarm(6, "c1", nil),
}
groups := groupServices(services, agents, onlineSet(1, 2, 3, 4), noBlock)
assert.Equal(t, []string{"Swarm", "mgr", "mgr2", "alpha", "zeta", "Unknown agent"}, titles(groups))
assert.Equal(t, []bool{false, true, true, false, false, false}, []bool{groups[0].Sub, groups[1].Sub, groups[2].Sub, groups[3].Sub, groups[4].Sub, groups[5].Sub})
})
t.Run("two clusters carry their ids in the titles", func(t *testing.T) {
groups := groupServices([]model.Service{swarm(1, "abcdefghijkl", nil), swarm(2, "xyz", nil)}, nil, onlineSet(), noBlock)
assert.Equal(t, []string{"Swarm abcdefgh", "Swarm xyz"}, titles(groups))
assert.Equal(t, "abcdefghijkl", groups[0].TitleHint)
})
t.Run("the auto-blocked reason is kept on the row", func(t *testing.T) {
groups := groupServices([]model.Service{compose(1, id(1), "web")}, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(1),
func(model.Service) string { return "because" })
assert.Equal(t, "because", groups[0].Services[0].AutoBlocked)
})
}