Commit c657efe681

c657efe6813287a05d91ba76f546738ab21750a4

parent: b0c28556b0

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

cmc <hello@cleberg.net> · 2026-09-07 18:40 UTC

store, control, web, wiki: list pending builds in the dashboard queue

QueueStatus lists pending builds after running ones, each row carrying
status and created_at; the text dashboard and /admin render both. A
build outside every runner's scope is now visible without walking
build list per repository.

Closes #186
.gitbay/wiki/Admin.org +3 −1
@@ -245,7 +245,9 @@ gitbay dashboard --json | jq .queues # webhooks, mail, mirrors, builds, deps
245245Per worker: pending, retrying (pending with a failed attempt) and
246246dead-lettered counts with the oldest pending age, and the retrying or
247247failed 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;
249251dependency checks list the ones whose last check errored. Non-admins get
250252no =queues= key at all.
251253
e2e/adminusers_test.go +12 −1
@@ -493,6 +493,12 @@ func TestAdminQueuesDashboard(t *testing.T) {
493493 Builds struct {
494494 Pending int64 `json:"pending"`
495495 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"`
496502 } `json:"builds"`
497503 Mail struct {
498504 Pending int64 `json:"pending"`
@@ -535,6 +541,11 @@ func TestAdminQueuesDashboard(t *testing.T) {
535541 if len(q.Webhooks.Items) == 0 || q.Webhooks.Items[0].Repo != "alice/app" || q.Webhooks.Items[0].Attempts == 0 || q.Webhooks.Items[0].LastError == "" {
536542 t.Fatalf("retrying item: %+v", q.Webhooks.Items)
537543 }
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 }
538549
539550 // The web page dispatches the same read; non-admins get a 404 and no
540551 // rail link.
@@ -548,7 +559,7 @@ func TestAdminQueuesDashboard(t *testing.T) {
548559 root := inst.login(t, rootKey)
549560 status, body := browserGet(t, root, inst.base()+"/admin")
550561 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>") {
552563 t.Fatalf("/admin: %d\n%s", status, body)
553564 }
554565 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 {
209209 }
210210 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
211211 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)
213217 }
214218 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
215219 for _, it := range q.Deps.Items {
internal/store/queues.go +8 −5
@@ -69,14 +69,16 @@ type QueueBuilds struct {
6969 Pending int64 `json:"pending"`
7070 Running int64 `json:"running"`
7171 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
7373}
7474
7575type QueueBuildRow struct {
7676 Repo string `json:"repo"`
7777 Number int64 `json:"number"`
7878 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
8082}
8183
8284type QueueDeps struct {
@@ -176,10 +178,11 @@ func (s *Store) QueueStatus() (Queues, error) {
176178 COALESCE(MIN(created_at) FILTER (WHERE status = 'pending'), '') FROM builds`).Scan(&q.Builds.Pending, &q.Builds.Running, &q.Builds.OldestPending); err != nil {
177179 return q, err
178180 }
179 if err := s.queryEach(`SELECT `+repoPathExpr+`, b.number, b.job, 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 {
181 if err := s.queryEach(`SELECT `+repoPathExpr+`, b.number, b.job, b.status, b.created_at, b.started_at
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 {
181184 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 {
183186 return err
184187 }
185188 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 @@
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 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>
35{{end}}</tbody></table></div>{{else}}<p class="none">No build running</p>{{end}}
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>{{.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 or pending</p>{{end}}
3636{{end}}
3737
3838{{with .Queues.Deps}}