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
+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)
}
}