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
128 lines
4.5 KiB
Go
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"`)
|
|
}
|