feat(master): Ledger UI for Services and Agents (#18) (#29)
Docker Build / docker (push) Successful in 41s
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:
@@ -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"`)
|
||||
}
|
||||
Reference in New Issue
Block a user