fix(master): review fixes for the Ledger UI (#18)

- icons/gen writes to the current directory so `go generate
  ./internal/master/web/icons` works (output verified byte-identical)
- share the registry-host check as imageref.IsRegistryHost (with test)
- agents fragment refreshes the "N machines" header count out of band
- ignore the saved machine filter at <=760px where the machine list is hidden
- aria-current on the active machine filter link, aria-pressed on theme toggle
- CLAUDE.md: icons package, SSE ping event, swarm_cluster_id

Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
This commit is contained in:
2026-10-13 10:25:02 +02:00
parent dd2b90bece
commit cdda174178
11 changed files with 71 additions and 16 deletions
+4 -5
View File
@@ -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(`<path d="([^"]+)"`)
@@ -66,10 +65,10 @@ func run() error {
if err := zw.Close(); err != nil {
return err
}
if err := os.WriteFile(path.Join(outDir, "simple-icons.json.gz"), gz.Bytes(), 0o644); err != nil {
if err := os.WriteFile("simple-icons.json.gz", gz.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(path.Join(outDir, "LICENSE-simple-icons.md"), license, 0o644); err != nil {
if err := os.WriteFile("LICENSE-simple-icons.md", license, 0o644); err != nil {
return err
}
fmt.Printf("wrote %d icons\n", len(icons))
+1 -5
View File
@@ -71,7 +71,7 @@ func Count() int {
// segment without a role suffix, and the namespace, in that order.
func ForImage(ref string) string {
segs := strings.Split(imageref.Parse(ref).Repo, "/")
if len(segs) > 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
+5 -1
View File
@@ -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
+7
View File
@@ -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")
+14 -2
View File
@@ -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) {
+9 -1
View File
@@ -1,7 +1,7 @@
{{define "content"}}
<div class="hdr ruled">
<h1>Agents</h1>
<span class="sub">{{.Nav.AgentsTotal}} {{if eq .Nav.AgentsTotal 1}}machine{{else}}machines{{end}}{{if .MasterVersion}} · master {{.MasterVersion}}{{end}}</span>
<span class="sub" id="agents-sub">{{template "agents-sub" .}}</span>
<div class="end"><a class="btn pri" href="/agents?add=1">Add agent</a></div>
</div>
<div id="agents" hx-get="/fragments/agents" hx-trigger="sse:agents" hx-swap="innerHTML">
@@ -9,6 +9,14 @@
</div>
{{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" .}}
<span class="sub" id="agents-sub" hx-swap-oob="true">{{template "agents-sub" .}}</span>
{{end}}
{{define "agents"}}
{{range .Agents}}
<div class="agent">
+1 -1
View File
@@ -39,7 +39,7 @@
{{end}}
{{define "machine-links"}}
<a class="cur" href="/" data-machine="">All <span class="n">{{.ServiceCount}}</span></a>
<a class="cur" href="/" aria-current="true" data-machine="">All <span class="n">{{.ServiceCount}}</span></a>
{{range .Groups}}
<a href="/"{{if .Sub}} class="sub"{{end}} data-machine="{{.Key}}">{{if .Cluster}}<span class="sw-icon" aria-hidden="true"></span>{{else}}<span class="mdot{{if .Offline}} off{{end}}" aria-hidden="true"></span>{{end}}{{.Title}} <span class="n">{{len .Services}}</span></a>
{{end}}
+1 -1
View File
@@ -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 != "" {