Files
shcizo ec28d1e027
Docker Build / docker (push) Successful in 41s
feat(master): Ledger UI for Services and Agents (#18) (#29)
## 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
2026-10-13 09:20:18 +00:00

128 lines
4.5 KiB
Go

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"`)
}