Commit 19682e7e50

19682e7e50918947f5d755bf5cd556f4c1a2a743

parent: a2d1580d3d

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-06 14:50 UTC

web: left-align admin table headers

The admin queue tables built their header rows without class="cols", so
the th fell back to the browser's centred default while the cells under
them were left-aligned.

Closes #151

Layout: unified · split

internal/web/templates/admin.html +5 −5
@@ -6,7 +6,7 @@
66{{with .Queues.Webhooks}}
77<h2>Webhook deliveries <span class="count">{{.Pending}}</span></h2>
88<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} dead-lettered{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p>
9{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr><th>Repository</th><th>URL</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody>
9{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>URL</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody>
1010{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{.URL}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}dead-lettered {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{if .Status}}{{.Status}} {{end}}{{.LastError}}</td></tr>
1111{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or dead-lettered</p>{{end}}
1212{{end}}
@@ -14,7 +14,7 @@
1414{{with .Queues.Mail}}
1515<h2>Mail <span class="count">{{.Pending}}</span></h2>
1616<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} failed{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p>
17{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr><th>Recipient</th><th>Subject</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody>
17{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Recipient</th><th>Subject</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody>
1818{{range .Items}}<tr><td>{{.Recipient}}</td><td>{{.Subject}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}failed {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{.LastError}}</td></tr>
1919{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}}
2020{{end}}
@@ -22,7 +22,7 @@
2222{{with .Queues.Mirrors}}
2323<h2>Mirrors <span class="count">{{.Errors}}</span></h2>
2424<p class="meta">{{.Dirty}} waiting for a sync · {{.Errors}} with a failed last sync</p>
25{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr><th>Repository</th><th>Direction</th><th>URL</th><th>Last sync</th><th>Error</th></tr></thead><tbody>
25{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Direction</th><th>URL</th><th>Last sync</th><th>Error</th></tr></thead><tbody>
2626{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{.Direction}}</td><td>{{.URL}}</td><td>{{if .LastSync}}{{when .LastSync}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr>
2727{{end}}</tbody></table></div>{{else}}<p class="none">Every mirror's last sync succeeded</p>{{end}}
2828{{end}}
@@ -30,14 +30,14 @@
3030{{with .Queues.Builds}}
3131<h2>Builds <span class="count">{{.Pending}}</span></h2>
3232<p class="meta">{{.Pending}} pending · {{.Running}} running{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p>
33{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr><th>Repository</th><th>Build</th><th>Job</th><th>Running since</th></tr></thead><tbody>
33{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Build</th><th>Job</th><th>Running since</th></tr></thead><tbody>
3434{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td><a href="/{{.Repo}}/builds/{{.Number}}">#{{.Number}}</a></td><td>{{.Job}}</td><td>{{when .StartedAt}}</td></tr>
3535{{end}}</tbody></table></div>{{else}}<p class="none">No build running</p>{{end}}
3636{{end}}
3737
3838{{with .Queues.Deps}}
3939<h2>Dependency checks <span class="count">{{.Errors}}</span></h2>
40{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr><th>Repository</th><th>Last check</th><th>Error</th></tr></thead><tbody>
40{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Last check</th><th>Error</th></tr></thead><tbody>
4141{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{if .LastCheck}}{{when .LastCheck}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr>
4242{{end}}</tbody></table></div>{{else}}<p class="none">No check has failed</p>{{end}}
4343{{end}}
internal/web/web_test.go +55 −1
@@ -1,6 +1,11 @@
11package web
22
3import "testing"
3import (
4 "io/fs"
5 "regexp"
6 "strings"
7 "testing"
8)
49
510// Every page template parses with the layout at start-up, so a broken
611// template fails the process rather than the first visit to its page.
@@ -17,3 +22,52 @@ func TestEveryPageTemplateParses(t *testing.T) {
1722 }
1823 }
1924}
25
26// A th with no rule of its own falls back to the browser's centred default
27// (#151). The classes that do left-align one are read out of the stylesheet
28// rather than listed here, so a new rule does not need a test change.
29func TestHeaderRowsAreLeftAligned(t *testing.T) {
30 aligned := map[string]bool{}
31 for _, rule := range strings.Split(string(StyleCSS), "}") {
32 sel, decls, ok := strings.Cut(rule, "{")
33 if !ok || !strings.Contains(decls, "text-align: left") || !strings.Contains(sel, "th") {
34 continue
35 }
36 for _, class := range regexp.MustCompile(`\.([a-z-]+)`).FindAllStringSubmatch(sel, -1) {
37 aligned[class[1]] = true
38 }
39 }
40 if len(aligned) == 0 {
41 t.Fatal("no left-aligning th rule found in the stylesheet")
42 }
43
44 classes := regexp.MustCompile(`class="([^"]*)"`)
45 entries, err := fs.ReadDir(templateFS, "templates")
46 if err != nil {
47 t.Fatal(err)
48 }
49 for _, e := range entries {
50 b, err := templateFS.ReadFile("templates/" + e.Name())
51 if err != nil {
52 t.Fatal(err)
53 }
54 // The table and row tags a header sits under, plus the header row
55 // itself: any one of them may carry the class.
56 for _, head := range strings.Split(string(b), "<thead>")[1:] {
57 open, _, _ := strings.Cut(head, "</tr>")
58 before := strings.Split(string(b), "<thead>"+head)[0]
59 if i := strings.LastIndex(before, "<table"); i >= 0 {
60 open += before[i:]
61 }
62 hit := false
63 for _, m := range classes.FindAllStringSubmatch(open, -1) {
64 for _, c := range strings.Fields(m[1]) {
65 hit = hit || aligned[c]
66 }
67 }
68 if !hit {
69 t.Errorf("%s: header row is not left-aligned by any rule: %.60s", e.Name(), open)
70 }
71 }
72 }
73}