feat(master): Ledger UI for Services and Agents (#18) (#29)
Docker Build / docker (push) Successful in 41s

## Summary

- Rebuilds the master's **Services** and **Agents** pages per the "Ledger" design: sidebar shell with live nav counts, machine filter and Live indicator; dark/light theme (follows the OS, toggle overrides); embedded Schibsted Grotesk and JetBrains Mono (fontsource 5.3.0, OFL).
- Services: Swarm grouped per cluster with its machines under it, offline banner ("Agent offline since HH:MM", browser-local), Simple Icons derived from the image ref (letter circle fallback), existing badges and mode switch restyled, client-side search, machine filter and Icon / Icon + text / Text toggle.
- Agents: Online / Offline / Never connected / Revoked, master version in the header with outdated agent versions marked, revoke via popover instead of `confirm()`, "Add agent" as a three-step panel that completes when the agent says hello (SSE `agents`).
- Store: migration `0005_agent_cluster.sql` persists each agent's `swarm_cluster_id` (needed to list a cluster's managers and its offline state after a master restart).
- SSE keepalive is now a named `ping` event so the Live indicator also shows the connection is alive. `revokeAgent` now publishes `agents`.

Closes #18

## Notes

- Workers never report a cluster id (Docker only sets `Swarm.Cluster` on managers), so their Swarm role shows "—". Fixing that needs a new hello field.
- Master binary grows by about 2.16 MB, almost all Simple Icons 16.35.0 path data (gzipped JSON, regenerated with `go generate ./internal/master/web/icons`). The agent binary does not import it.
- Jobs, Job and Service detail pages only get the new shell; their own artboards are out of scope, as are Update buttons, Update all, header stats and the drift badge.
- Unchanged: CSRF on every POST, `Cache-Control: no-store` on the token response, 401 + `HX-Redirect` without a session, no `template.HTML` on agent- or user-controlled values, no Node build step.

## Test plan

- [x] `go test ./...`, `go test -race ./internal/master/...`
- [x] `go test -tags integration ./internal/master/store/` against Postgres 17
- [x] `docker build`
- [x] Browser check on fake data: both themes, 1440 px and 390 px, add-agent flow through connect
- [ ] Services grouping and icons against real stacks and a real Swarm cluster
- [ ] "Agent offline since" after stopping an agent
- [ ] Theme toggle, search and machine filter across reloads and mode changes
- [ ] Add agent end to end with a real agent, Copy buttons
- [ ] Phone at 390 px

https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
2026-10-13 09:20:18 +00:00
parent 919ad91710
commit ec28d1e027
29 changed files with 2140 additions and 199 deletions
+128 -25
View File
@@ -221,30 +221,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 {
@@ -279,13 +295,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) {
@@ -293,15 +340,64 @@ 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 TestAgentsFragment_RefreshesHeaderCount(t *testing.T) {
srv, _, _, _ := newTestServer(t)
code, body := get(t, srv, "/fragments/agents")
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `id="agents-sub" hx-swap-oob="true">3 machines`)
}
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) {
@@ -319,6 +415,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) {
@@ -358,16 +458,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) {
@@ -650,16 +750,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)
}
}