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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user