diff --git a/CLAUDE.md b/CLAUDE.md index e23419d..0483f06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ - `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs - `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent - `internal/master/webhook` — `POST /update`: records the available version, starts jobs for auto services, waits for them +- `internal/master/web/icons` — embedded Simple Icons path data; slug derived from the image ref; regenerate with `go generate ./internal/master/web/icons` - `internal/master/web`, `webauth` — HTMX UI behind OIDC login, SSE signals - `internal/master/events` — in-process publish/subscribe for UI refresh signals - `internal/master/model` — domain types shared by the above @@ -138,6 +139,8 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ newer suggestion, and a digest-pinned pick does not clear a digest-less (webhook) one. - **Job output is capped at `protocol.MaxJobOutput` (128 KiB, tail).** NUL bytes are stripped by the hub because Postgres text rejects them. +- **The SSE stream sends a named `ping` event every 25 s**; the sidebar Live indicator relies on it. +- **Agents persist `swarm_cluster_id`.** Only managers report it, so workers show no role. ## References diff --git a/internal/imageref/imageref.go b/internal/imageref/imageref.go index cd268b1..5d375bd 100644 --- a/internal/imageref/imageref.go +++ b/internal/imageref/imageref.go @@ -30,6 +30,12 @@ func Parse(ref string) Ref { return r } +// IsRegistryHost reports whether the first path segment of a repository names +// a registry: it contains a "." or ":" or is "localhost". +func IsRegistryHost(segment string) bool { + return strings.ContainsAny(segment, ".:") || segment == "localhost" +} + // String reassembles the reference as repo[:tag][@digest]. func (r Ref) String() string { s := r.Repo diff --git a/internal/imageref/imageref_test.go b/internal/imageref/imageref_test.go index c86414b..f4cd9b2 100644 --- a/internal/imageref/imageref_test.go +++ b/internal/imageref/imageref_test.go @@ -27,3 +27,23 @@ func TestParse(t *testing.T) { }) } } + +func TestIsRegistryHost(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"lscr.io", true}, + {"localhost:5000", true}, + {"localhost", true}, + {"registry:5000", true}, + {"gitea", false}, + {"library", false}, + {"", false}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + assert.Equal(t, c.want, IsRegistryHost(c.in)) + }) + } +} diff --git a/internal/master/web/icons/gen/main.go b/internal/master/web/icons/gen/main.go index e09d998..d8468e4 100644 --- a/internal/master/web/icons/gen/main.go +++ b/internal/master/web/icons/gen/main.go @@ -1,9 +1,9 @@ // Command gen builds simple-icons.json.gz, the icon data the icons package // embeds. It downloads the pinned simple-icons npm tarball, checks its // sha256 and writes a gzipped JSON object {slug: svg path data}. The output -// is deterministic. Run it from the repository root: +// is deterministic. It writes into the current directory; run it with // -// go run ./internal/master/web/icons/gen +// go generate ./internal/master/web/icons package main import ( @@ -25,7 +25,6 @@ import ( const ( tarballURL = "https://registry.npmjs.org/simple-icons/-/simple-icons-16.35.0.tgz" tarballSHA256 = "20ebead4f6bc93464812a5e260d19fc4d84772e5eeff69a6fbea263ca2441c5f" - outDir = "internal/master/web/icons" ) var pathData = regexp.MustCompile(` 1 && isRegistryHost(segs[0]) { + if len(segs) > 1 && imageref.IsRegistryHost(segs[0]) { segs = segs[1:] } last := segs[len(segs)-1] @@ -97,10 +97,6 @@ func ForImage(ref string) string { return "" } -func isRegistryHost(s string) bool { - return strings.ContainsAny(s, ".:") || s == "localhost" -} - // normalise lowercases s and keeps only a-z and 0-9, the form of Simple Icons slugs. func normalise(s string) string { var b strings.Builder diff --git a/internal/master/web/server.go b/internal/master/web/server.go index 00e59b1..7de3f93 100644 --- a/internal/master/web/server.go +++ b/internal/master/web/server.go @@ -559,7 +559,11 @@ func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) { s.fail(w, err) return } - s.render(w, http.StatusOK, "agents.html", "agents", data) + if data.Nav, err = s.navData(r.Context(), "agents"); err != nil { + s.fail(w, err) + return + } + s.render(w, http.StatusOK, "agents.html", "agents-fragment", data) } // agentConnectFragment is the last step of the "add agent" panel: it says diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index c5c1626..18e4317 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -346,6 +346,13 @@ func TestAgentsPage_ShowsConnectionState(t *testing.T) { assert.NotContains(t, body, `class="drawer"`, "the add panel is closed by default") } +func TestAgentsFragment_RefreshesHeaderCount(t *testing.T) { + srv, _, _, _ := newTestServer(t) + code, body := get(t, srv, "/fragments/agents") + assert.Equal(t, http.StatusOK, code) + assert.Contains(t, body, `id="agents-sub" hx-swap-oob="true">3 machines`) +} + func TestAgentsPage_RevokeUsesPopover(t *testing.T) { srv, _, _, _ := newTestServer(t) _, body := get(t, srv, "/agents") diff --git a/internal/master/web/static/app.js b/internal/master/web/static/app.js index 706fa07..8704a37 100644 --- a/internal/master/web/static/app.js +++ b/internal/master/web/static/app.js @@ -24,8 +24,10 @@ themeButton.addEventListener('click', function () { var light = !root.classList.contains('t-light'); root.classList.toggle('t-light', light); + themeButton.setAttribute('aria-pressed', String(light)); save('localStorage', 'pu-theme', light ? 'light' : 'dark'); }); + themeButton.setAttribute('aria-pressed', String(root.classList.contains('t-light'))); } if (colorScheme && colorScheme.addEventListener) { colorScheme.addEventListener('change', function (e) { @@ -50,16 +52,21 @@ var search = document.getElementById('q'); if (search) search.value = filter.q; + // The machine list is hidden on narrow screens; a filter that cannot be + // seen or cleared there is ignored. Search still applies. + var narrow = window.matchMedia ? window.matchMedia('(max-width:760px)') : null; + function applyFilter() { var box = document.getElementById('services'); if (!box) return; var groups = box.querySelectorAll('.grp'); var known = Array.prototype.some.call(groups, function (g) { return g.dataset.key === filter.machine; }); if (filter.machine && !known) filter.machine = ''; + var machine = narrow && narrow.matches ? '' : filter.machine; var q = filter.q.trim().toLowerCase(); var any = false; Array.prototype.forEach.call(groups, function (g) { - var inMachine = !filter.machine || g.dataset.key === filter.machine; + var inMachine = !machine || g.dataset.key === machine; var shown = 0; Array.prototype.forEach.call(g.querySelectorAll('.row'), function (row) { var match = inMachine && (!q || (row.dataset.search || '').indexOf(q) !== -1); @@ -71,8 +78,13 @@ }); var none = document.getElementById('nomatch'); if (none) none.hidden = any || groups.length === 0; - each('#machines a', function (a) { a.classList.toggle('cur', a.dataset.machine === filter.machine); }); + each('#machines a', function (a) { + var cur = a.dataset.machine === filter.machine; + a.classList.toggle('cur', cur); + if (cur) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current'); + }); } + if (narrow && narrow.addEventListener) narrow.addEventListener('change', function () { applyFilter(); }); function saveFilter() { save('sessionStorage', 'pu-filter', JSON.stringify(filter)); } if (search) { diff --git a/internal/master/web/templates/agents.html b/internal/master/web/templates/agents.html index 14fabcf..c997917 100644 --- a/internal/master/web/templates/agents.html +++ b/internal/master/web/templates/agents.html @@ -1,7 +1,7 @@ {{define "content"}}

Agents

- {{.Nav.AgentsTotal}} {{if eq .Nav.AgentsTotal 1}}machine{{else}}machines{{end}}{{if .MasterVersion}} · master {{.MasterVersion}}{{end}} + {{template "agents-sub" .}}
@@ -9,6 +9,14 @@
{{end}} +{{define "agents-sub"}}{{.Nav.AgentsTotal}} {{if eq .Nav.AgentsTotal 1}}machine{{else}}machines{{end}}{{if .MasterVersion}} · master {{.MasterVersion}}{{end}}{{end}} + +{{/* What /fragments/agents returns: the list, and the header count swapped in out of band. */}} +{{define "agents-fragment"}} +{{template "agents" .}} +{{template "agents-sub" .}} +{{end}} + {{define "agents"}} {{range .Agents}}
diff --git a/internal/master/web/templates/overview.html b/internal/master/web/templates/overview.html index 0961493..fdefe5c 100644 --- a/internal/master/web/templates/overview.html +++ b/internal/master/web/templates/overview.html @@ -39,7 +39,7 @@ {{end}} {{define "machine-links"}} -All {{.ServiceCount}} +All {{.ServiceCount}} {{range .Groups}} {{if .Cluster}}{{else}}{{end}}{{.Title}} {{len .Services}} {{end}} diff --git a/internal/master/web/views.go b/internal/master/web/views.go index 3f1d682..95347c4 100644 --- a/internal/master/web/views.go +++ b/internal/master/web/views.go @@ -92,7 +92,7 @@ func newServiceRow(s model.Service, autoBlocked string) serviceRow { func imageParts(ref string) (repo, tag, digest string) { r := imageref.Parse(ref) repo = r.Repo - if host, rest, ok := strings.Cut(repo, "/"); ok && (strings.ContainsAny(host, ".:") || host == "localhost") { + if host, rest, ok := strings.Cut(repo, "/"); ok && (imageref.IsRegistryHost(host)) { repo = rest } if r.Tag != "" {