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:
@@ -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