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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user