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 |
| 245 | 245 | Per worker: pending, retrying (pending with a failed attempt) and |
| 246 | 246 | dead-lettered counts with the oldest pending age, and the retrying or |
| 247 | 247 | failed rows themselves, capped at twenty each. Builds list what is |
| 248 | | running and since when; mirrors list the ones whose last sync failed; |
| 248 | running and then what is pending, each since when, so a build no runner |
| 249 | is scoped to claim is visible here rather than only in its repository; |
| 250 | mirrors list the ones whose last sync failed; |
| 249 | 251 | dependency checks list the ones whose last check errored. Non-admins get |
| 250 | 252 | no =queues= key at all. |
| 251 | 253 | |
e2e/adminusers_test.go
+12 −1
| @@ -493,6 +493,12 @@ func TestAdminQueuesDashboard(t *testing.T) { |
| 493 | 493 | Builds struct { |
| 494 | 494 | Pending int64 `json:"pending"` |
| 495 | 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 | 502 | } `json:"builds"` |
| 497 | 503 | Mail struct { |
| 498 | 504 | Pending int64 `json:"pending"` |
| @@ -535,6 +541,11 @@ func TestAdminQueuesDashboard(t *testing.T) { |
| 535 | 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 | 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 | 550 | // The web page dispatches the same read; non-admins get a 404 and no |
| 540 | 551 | // rail link. |
| @@ -548,7 +559,7 @@ func TestAdminQueuesDashboard(t *testing.T) { |
| 548 | 559 | root := inst.login(t, rootKey) |
| 549 | 560 | status, body := browserGet(t, root, inst.base()+"/admin") |
| 550 | 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 | 563 | t.Fatalf("/admin: %d\n%s", status, body) |
| 553 | 564 | } |
| 554 | 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 | 210 | fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running) |
| 211 | 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 | 218 | fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors) |
| 215 | 219 | for _, it := range q.Deps.Items { |
internal/store/queues.go
+8 −5
| @@ -69,14 +69,16 @@ type QueueBuilds struct { |
| 69 | 69 | Pending int64 `json:"pending"` |
| 70 | 70 | Running int64 `json:"running"` |
| 71 | 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 | |
| 75 | 75 | type QueueBuildRow struct { |
| 76 | 76 | Repo string `json:"repo"` |
| 77 | 77 | Number int64 `json:"number"` |
| 78 | 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 | |
| 82 | 84 | type QueueDeps struct { |
| @@ -176,10 +178,11 @@ func (s *Store) QueueStatus() (Queues, error) { |
| 176 | 178 | COALESCE(MIN(created_at) FILTER (WHERE status = 'pending'), '') FROM builds`).Scan(&q.Builds.Pending, &q.Builds.Running, &q.Builds.OldestPending); err != nil { |
| 177 | 179 | return q, err |
| 178 | 180 | } |
| 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 { |
| 181 | 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 | 186 | return err |
| 184 | 187 | } |
| 185 | 188 | q.Builds.Items = append(q.Builds.Items, b) |
internal/store/queues_test.go
added
+48
| @@ -0,0 +1,48 @@ |
| 1 | package store |
| 2 | |
| 3 | import "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. |
| 7 | func 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 | 30 | {{with .Queues.Builds}} |
| 31 | 31 | <h2>Builds <span class="count">{{.Pending}}</span></h2> |
| 32 | 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> |
| 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}} |
| 36 | 36 | {{end}} |
| 37 | 37 | |
| 38 | 38 | {{with .Queues.Deps}} |