dashboard: list pending builds in the build queue !331

merged merged by cmc on 2026-09-07 18:50 UTC · krz/gitbay:queue-pending into main

6 files changed, +79 −11

Layout: unified · split

.gitbay/wiki/Admin.org +3 −1
@@ -245,7 +245,9 @@ gitbay dashboard --json | jq .queues # webhooks, mail, mirrors, builds, deps
245Per worker: pending, retrying (pending with a failed attempt) and 245Per worker: pending, retrying (pending with a failed attempt) and
246dead-lettered counts with the oldest pending age, and the retrying or 246dead-lettered counts with the oldest pending age, and the retrying or
247failed rows themselves, capped at twenty each. Builds list what is 247failed rows themselves, capped at twenty each. Builds list what is
248running and since when; mirrors list the ones whose last sync failed; 248running and then what is pending, each since when, so a build no runner
249is scoped to claim is visible here rather than only in its repository;
250mirrors list the ones whose last sync failed;
249dependency checks list the ones whose last check errored. Non-admins get 251dependency checks list the ones whose last check errored. Non-admins get
250no =queues= key at all. 252no =queues= key at all.
251 253
e2e/adminusers_test.go +12 −1
@@ -493,6 +493,12 @@ func TestAdminQueuesDashboard(t *testing.T) {
493 Builds struct { 493 Builds struct {
494 Pending int64 `json:"pending"` 494 Pending int64 `json:"pending"`
495 OldestPending string `json:"oldest_pending"` 495 OldestPending string `json:"oldest_pending"`
496 Items []struct {
497 Repo string `json:"repo"`
498 Job string `json:"job"`
499 Status string `json:"status"`
500 CreatedAt string `json:"created_at"`
501 } `json:"items"`
496 } `json:"builds"` 502 } `json:"builds"`
497 Mail struct { 503 Mail struct {
498 Pending int64 `json:"pending"` 504 Pending int64 `json:"pending"`
@@ -535,6 +541,11 @@ func TestAdminQueuesDashboard(t *testing.T) {
535 if len(q.Webhooks.Items) == 0 || q.Webhooks.Items[0].Repo != "alice/app" || q.Webhooks.Items[0].Attempts == 0 || q.Webhooks.Items[0].LastError == "" { 541 if len(q.Webhooks.Items) == 0 || q.Webhooks.Items[0].Repo != "alice/app" || q.Webhooks.Items[0].Attempts == 0 || q.Webhooks.Items[0].LastError == "" {
536 t.Fatalf("retrying item: %+v", q.Webhooks.Items) 542 t.Fatalf("retrying item: %+v", q.Webhooks.Items)
537 } 543 }
544 // A build no runner has claimed is listed, not just counted.
545 if len(q.Builds.Items) == 0 || q.Builds.Items[0].Repo != "alice/app" || q.Builds.Items[0].Job != "ok" ||
546 q.Builds.Items[0].Status != "pending" || q.Builds.Items[0].CreatedAt == "" {
547 t.Fatalf("pending build item: %+v", q.Builds.Items)
548 }
538 549
539 // The web page dispatches the same read; non-admins get a 404 and no 550 // The web page dispatches the same read; non-admins get a 404 and no
540 // rail link. 551 // rail link.
@@ -548,7 +559,7 @@ func TestAdminQueuesDashboard(t *testing.T) {
548 root := inst.login(t, rootKey) 559 root := inst.login(t, rootKey)
549 status, body := browserGet(t, root, inst.base()+"/admin") 560 status, body := browserGet(t, root, inst.base()+"/admin")
550 if status != 200 || !strings.Contains(body, "Webhook deliveries") || !strings.Contains(body, "alice/app") || 561 if status != 200 || !strings.Contains(body, "Webhook deliveries") || !strings.Contains(body, "alice/app") ||
551 !strings.Contains(body, "retrying") || !strings.Contains(body, "1 pending") { 562 !strings.Contains(body, "retrying") || !strings.Contains(body, "1 pending") || !strings.Contains(body, "<td>pending</td>") {
552 t.Fatalf("/admin: %d\n%s", status, body) 563 t.Fatalf("/admin: %d\n%s", status, body)
553 } 564 }
554 if _, body := browserGet(t, root, inst.base()+"/"); !strings.Contains(body, `href="/admin"`) { 565 if _, body := browserGet(t, root, inst.base()+"/"); !strings.Contains(body, `href="/admin"`) {
internal/control/dashboard.go +5 −1
@@ -209,7 +209,11 @@ func runDashboard(c *Ctx, args []string) int {
209 } 209 }
210 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running) 210 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
211 for _, it := range q.Builds.Items { 211 for _, it := range q.Builds.Items {
212 fmt.Fprintf(w, " %s\t%d\t%s\tsince %s\n", it.Repo, it.Number, it.Job, it.StartedAt) 212 since := it.StartedAt
213 if it.Status == "pending" {
214 since = it.CreatedAt
215 }
216 fmt.Fprintf(w, " %s\t%d\t%s\t%s since %s\n", it.Repo, it.Number, it.Job, it.Status, since)
213 } 217 }
214 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors) 218 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
215 for _, it := range q.Deps.Items { 219 for _, it := range q.Deps.Items {
internal/store/queues.go +8 −5
@@ -69,14 +69,16 @@ type QueueBuilds struct {
69 Pending int64 `json:"pending"` 69 Pending int64 `json:"pending"`
70 Running int64 `json:"running"` 70 Running int64 `json:"running"`
71 OldestPending string `json:"oldest_pending,omitempty"` 71 OldestPending string `json:"oldest_pending,omitempty"`
72 Items []QueueBuildRow `json:"items"` // running builds, oldest first 72 Items []QueueBuildRow `json:"items"` // running then pending, oldest first
73} 73}
74 74
75type QueueBuildRow struct { 75type QueueBuildRow struct {
76 Repo string `json:"repo"` 76 Repo string `json:"repo"`
77 Number int64 `json:"number"` 77 Number int64 `json:"number"`
78 Job string `json:"job"` 78 Job string `json:"job"`
79 StartedAt string `json:"started_at"` 79 Status string `json:"status"` // running | pending
80 CreatedAt string `json:"created_at"`
81 StartedAt string `json:"started_at"` // "" while pending
80} 82}
81 83
82type QueueDeps struct { 84type QueueDeps struct {
@@ -176,10 +178,11 @@ func (s *Store) QueueStatus() (Queues, error) {
176 COALESCE(MIN(created_at) FILTER (WHERE status = 'pending'), '') FROM builds`).Scan(&q.Builds.Pending, &q.Builds.Running, &q.Builds.OldestPending); err != nil { 178 COALESCE(MIN(created_at) FILTER (WHERE status = 'pending'), '') FROM builds`).Scan(&q.Builds.Pending, &q.Builds.Running, &q.Builds.OldestPending); err != nil {
177 return q, err 179 return q, err
178 } 180 }
179 if err := s.queryEach(`SELECT `+repoPathExpr+`, b.number, b.job, b.started_at 181 if err := s.queryEach(`SELECT `+repoPathExpr+`, b.number, b.job, b.status, b.created_at, b.started_at
180 FROM builds b`+repoJoin("b.repo_id")+` WHERE b.status = 'running' ORDER BY b.started_at LIMIT ?`, func(sc scanner) error { 182 FROM builds b`+repoJoin("b.repo_id")+` WHERE b.status IN ('running', 'pending')
183 ORDER BY b.status = 'running' DESC, b.started_at, b.created_at, b.id LIMIT ?`, func(sc scanner) error {
181 var b QueueBuildRow 184 var b QueueBuildRow
182 if err := sc.Scan(&b.Repo, &b.Number, &b.Job, &b.StartedAt); err != nil { 185 if err := sc.Scan(&b.Repo, &b.Number, &b.Job, &b.Status, &b.CreatedAt, &b.StartedAt); err != nil {
183 return err 186 return err
184 } 187 }
185 q.Builds.Items = append(q.Builds.Items, b) 188 q.Builds.Items = append(q.Builds.Items, b)
internal/store/queues_test.go added +48
@@ -0,0 +1,48 @@
1package store
2
3import "testing"
4
5// The build queue lists pending builds as well as running ones, so an
6// admin can see what no runner is claiming without walking every repo.
7func TestQueuesListsPendingBuilds(t *testing.T) {
8 s := open(t)
9 if err := s.MigrateUp(); err != nil {
10 t.Fatal(err)
11 }
12 uid, err := s.CreateUser("cmc", true)
13 if err != nil {
14 t.Fatal(err)
15 }
16 if _, err := s.CreateRepo("user", uid, "orgo", "public"); err != nil {
17 t.Fatal(err)
18 }
19 running, err := s.CreateBuild(1, "test", "abc123", "main", `["true"]`, "", "", true)
20 if err != nil {
21 t.Fatal(err)
22 }
23 pending, err := s.CreateBuild(1, "pages", "abc123", "main", `["true"]`, "", "", true)
24 if err != nil {
25 t.Fatal(err)
26 }
27 if _, ok, err := s.ClaimBuild(nil); err != nil || !ok {
28 t.Fatalf("claim: %v ok=%v", err, ok)
29 }
30
31 q, err := s.QueueStatus()
32 if err != nil {
33 t.Fatal(err)
34 }
35 if q.Builds.Pending != 1 || q.Builds.Running != 1 {
36 t.Fatalf("counts: %+v", q.Builds)
37 }
38 items := q.Builds.Items
39 if len(items) != 2 {
40 t.Fatalf("items: %+v", items)
41 }
42 if items[0].Number != running || items[0].Status != "running" || items[0].StartedAt == "" {
43 t.Fatalf("running row first: %+v", items[0])
44 }
45 if items[1].Number != pending || items[1].Status != "pending" || items[1].StartedAt != "" || items[1].CreatedAt == "" {
46 t.Fatalf("pending row after: %+v", items[1])
47 }
48}
internal/web/templates/admin.html +3 −3
@@ -30,9 +30,9 @@
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 class="cols"><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>Status</th><th>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>{{.Status}}</td><td>{{if .StartedAt}}{{when .StartedAt}}{{else}}{{when .CreatedAt}}{{end}}</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 or pending</p>{{end}}
36{{end}} 36{{end}}
37 37
38{{with .Queues.Deps}} 38{{with .Queues.Deps}}