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 @@
6{{with .Queues.Webhooks}} 6{{with .Queues.Webhooks}}
7<h2>Webhook deliveries <span class="count">{{.Pending}}</span></h2> 7<h2>Webhook deliveries <span class="count">{{.Pending}}</span></h2>
8<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} dead-lettered{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> 8<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>
10{{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> 10{{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>
11{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or dead-lettered</p>{{end}} 11{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or dead-lettered</p>{{end}}
12{{end}} 12{{end}}
@@ -14,7 +14,7 @@
14{{with .Queues.Mail}} 14{{with .Queues.Mail}}
15<h2>Mail <span class="count">{{.Pending}}</span></h2> 15<h2>Mail <span class="count">{{.Pending}}</span></h2>
16<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} failed{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> 16<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>
18{{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> 18{{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>
19{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}} 19{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}}
20{{end}} 20{{end}}
@@ -22,7 +22,7 @@
22{{with .Queues.Mirrors}} 22{{with .Queues.Mirrors}}
23<h2>Mirrors <span class="count">{{.Errors}}</span></h2> 23<h2>Mirrors <span class="count">{{.Errors}}</span></h2>
24<p class="meta">{{.Dirty}} waiting for a sync · {{.Errors}} with a failed last sync</p> 24<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>
26{{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> 26{{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>
27{{end}}</tbody></table></div>{{else}}<p class="none">Every mirror's last sync succeeded</p>{{end}} 27{{end}}</tbody></table></div>{{else}}<p class="none">Every mirror's last sync succeeded</p>{{end}}
28{{end}} 28{{end}}
@@ -30,14 +30,14 @@
30{{with .Queues.Builds}} 30{{with .Queues.Builds}}
31<h2>Builds <span class="count">{{.Pending}}</span></h2> 31<h2>Builds <span class="count">{{.Pending}}</span></h2>
32<p class="meta">{{.Pending}} pending · {{.Running}} running{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> 32<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>
34{{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> 34{{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>
35{{end}}</tbody></table></div>{{else}}<p class="none">No build running</p>{{end}} 35{{end}}</tbody></table></div>{{else}}<p class="none">No build running</p>{{end}}
36{{end}} 36{{end}}
37 37
38{{with .Queues.Deps}} 38{{with .Queues.Deps}}
39<h2>Dependency checks <span class="count">{{.Errors}}</span></h2> 39<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>
41{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{if .LastCheck}}{{when .LastCheck}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr> 41{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{if .LastCheck}}{{when .LastCheck}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr>
42{{end}}</tbody></table></div>{{else}}<p class="none">No check has failed</p>{{end}} 42{{end}}</tbody></table></div>{{else}}<p class="none">No check has failed</p>{{end}}
43{{end}} 43{{end}}
internal/web/web_test.go +55 −1
@@ -1,6 +1,11 @@
1package web 1package web
2 2
3import "testing" 3import (
4 "io/fs"
5 "regexp"
6 "strings"
7 "testing"
8)
4 9
5// Every page template parses with the layout at start-up, so a broken 10// Every page template parses with the layout at start-up, so a broken
6// template fails the process rather than the first visit to its page. 11// template fails the process rather than the first visit to its page.
@@ -17,3 +22,52 @@ func TestEveryPageTemplateParses(t *testing.T) {
17 } 22 }
18 } 23 }
19} 24}
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}