Commit 8c7a8b7eef

8c7a8b7eef64ee5441d2115112887ec2f8b73639

parent: bfe9eee44c

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

cmc <hello@cleberg.net> · 2026-09-02 01:49 UTC

dashboard: worker queues for admins, and an /admin page

Four background workers keep failure state and none had an instance-wide
view: webhook deliveries and mirrors were readable per repository, builds
per user, and the mail queue nowhere.

dashboard now carries a queues block for instance admins: pending,
retrying and dead-lettered counts per worker with the oldest pending
age, plus the retrying and failed rows themselves (capped at twenty
each), running builds with their start time, dirty and failing mirrors,
and failed dependency checks. Non-admins see no such key.

/admin renders that block by dispatching dashboard, in accounts mode
only. Non-admins get the 404 a missing page gives and no rail link.

Closes #74

Layout: unified · split

e2e/adminusers_test.go +104
@@ -6,6 +6,7 @@ import (
66 "path/filepath"
77 "strings"
88 "testing"
9 "time"
910)
1011
1112type adminUserRow struct {
@@ -451,3 +452,106 @@ func TestAuditFilters(t *testing.T) {
451452 t.Fatalf("host audit --json --actor:\n%s", out)
452453 }
453454}
455
456func TestAdminQueuesDashboard(t *testing.T) {
457 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[webhooks]\nallow_local = true\n")
458 rootKey := inst.newKey(t, "root")
459 aliceKey := inst.newKey(t, "alice")
460 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
461 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
462 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
463 t.Fatal("repo create failed")
464 }
465 // A webhook whose receiver keeps failing, and a CI job with no runner:
466 // one delivery retrying, one build pending.
467 hook := startHookReceiver(t)
468 hook.failNext = 100
469 if _, errOut, code := inst.ssh(t, aliceKey, "", "webhook", "add", "alice/app", "http://"+hook.addr+"/hook"); code != 0 {
470 t.Fatalf("webhook add: %s", errOut)
471 }
472 work := t.TempDir()
473 env := inst.gitEnv(aliceKey)
474 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
475 dir := filepath.Join(work, "w")
476 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
477 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n ok:\n steps:\n - echo fine\n"), 0o644)
478 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
479 mustGit(t, dir, env, "add", ".")
480 mustGit(t, dir, env, "commit", "-q", "-m", "ci")
481 mustGit(t, dir, env, "push", "-q", "origin", "main")
482
483 type queues struct {
484 Webhooks struct {
485 Pending int64 `json:"pending"`
486 Retrying int64 `json:"retrying"`
487 Items []struct {
488 Repo string `json:"repo"`
489 Attempts int64 `json:"attempts"`
490 LastError string `json:"last_error"`
491 } `json:"items"`
492 } `json:"webhooks"`
493 Builds struct {
494 Pending int64 `json:"pending"`
495 OldestPending string `json:"oldest_pending"`
496 } `json:"builds"`
497 Mail struct {
498 Pending int64 `json:"pending"`
499 } `json:"mail"`
500 }
501 dashboard := func(key string) (*queues, string) {
502 t.Helper()
503 out, errOut, code := inst.ssh(t, key, "", "dashboard", "--json")
504 if code != 0 {
505 t.Fatalf("dashboard: exit %d %s", code, errOut)
506 }
507 var env struct {
508 Data struct {
509 Queues *queues `json:"queues"`
510 } `json:"data"`
511 }
512 if err := json.Unmarshal([]byte(out), &env); err != nil {
513 t.Fatalf("dashboard json: %v\n%s", err, out)
514 }
515 return env.Data.Queues, out
516 }
517 if q, out := dashboard(aliceKey); q != nil {
518 t.Fatalf("non-admin dashboard carries queues:\n%s", out)
519 }
520 var q *queues
521 deadline := time.Now().Add(20 * time.Second)
522 for {
523 q, _ = dashboard(rootKey)
524 if q != nil && q.Webhooks.Retrying >= 1 && q.Builds.Pending >= 1 {
525 break
526 }
527 if time.Now().After(deadline) {
528 t.Fatalf("queues never showed the retrying delivery and pending build: %+v", q)
529 }
530 time.Sleep(200 * time.Millisecond)
531 }
532 if q.Builds.OldestPending == "" || q.Mail.Pending != 0 {
533 t.Fatalf("queue facts: %+v", q)
534 }
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 == "" {
536 t.Fatalf("retrying item: %+v", q.Webhooks.Items)
537 }
538
539 // The web page dispatches the same read; non-admins get a 404 and no
540 // rail link.
541 alice := inst.login(t, aliceKey)
542 if status, body := browserGet(t, alice, inst.base()+"/admin"); status != 404 || strings.Contains(body, "Webhook deliveries") {
543 t.Fatalf("non-admin /admin: %d", status)
544 }
545 if _, body := browserGet(t, alice, inst.base()+"/"); strings.Contains(body, `href="/admin"`) {
546 t.Fatal("non-admin rail links to /admin")
547 }
548 root := inst.login(t, rootKey)
549 status, body := browserGet(t, root, inst.base()+"/admin")
550 if status != 200 || !strings.Contains(body, "Webhook deliveries") || !strings.Contains(body, "alice/app") ||
551 !strings.Contains(body, "retrying") || !strings.Contains(body, "1 pending") {
552 t.Fatalf("/admin: %d\n%s", status, body)
553 }
554 if _, body := browserGet(t, root, inst.base()+"/"); !strings.Contains(body, `href="/admin"`) {
555 t.Fatal("admin rail lacks /admin")
556 }
557}
internal/control/dashboard.go +31
@@ -70,6 +70,9 @@ func runDashboard(c *Ctx, args []string) int {
7070 Activity []feedOut `json:"recent_activity"`
7171 Builds []buildOut `json:"builds"`
7272 Server *serverOut `json:"server,omitempty"`
73 // Queues is admin-only: every background worker's backlog and
74 // failures, the operator's view of what is stuck.
75 Queues *store.Queues `json:"queues,omitempty"`
7376 }
7477 d := out{
7578 Reviews: []dashboardItem{}, Assigned: []dashboardItem{}, MRs: []dashboardItem{},
@@ -139,6 +142,11 @@ func runDashboard(c *Ctx, args []string) int {
139142 }
140143 if c.User.IsAdmin {
141144 d.Server = &serverOut{Commit: buildinfo.String()}
145 q, err := c.Store.QueueStatus()
146 if err != nil {
147 return c.fail(protocol.ExitFailure, "%v", err)
148 }
149 d.Queues = &q
142150 }
143151
144152 return c.emit(d, func(w io.Writer) {
@@ -169,6 +177,29 @@ func runDashboard(c *Ctx, args []string) int {
169177 if d.Server != nil {
170178 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit)
171179 }
180 if q := d.Queues; q != nil {
181 fmt.Fprintln(w, "queues:")
182 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
183 for _, it := range q.Webhooks.Items {
184 fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Repo, it.URL, it.Attempts, it.LastError)
185 }
186 fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
187 for _, it := range q.Mail.Items {
188 fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Recipient, it.Subject, it.Attempts, it.LastError)
189 }
190 fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
191 for _, it := range q.Mirrors.Items {
192 fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", it.Repo, it.Direction, it.URL, it.LastError)
193 }
194 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
195 for _, it := range q.Builds.Items {
196 fmt.Fprintf(w, " %s\t%d\t%s\tsince %s\n", it.Repo, it.Number, it.Job, it.StartedAt)
197 }
198 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
199 for _, it := range q.Deps.Items {
200 fmt.Fprintf(w, " %s\t%s\n", it.Repo, it.LastError)
201 }
202 }
172203 })
173204}
174205
internal/httpd/admin.go added +34
@@ -0,0 +1,34 @@
1package httpd
2
3import (
4 "net/http"
5
6 "gitbay.org/gitbay/internal/store"
7)
8
9// adminPage is the operator's view: the running build and every worker
10// queue, read through the dashboard command's admin-only block. A
11// non-admin gets the same 404 a missing page would, so the URL confirms
12// nothing.
13func (s *Server) adminPage(w http.ResponseWriter, r *http.Request, viewer store.User) {
14 if !viewer.IsAdmin {
15 s.notFound(w, r)
16 return
17 }
18 var d struct {
19 Server struct {
20 Commit string `json:"commit"`
21 } `json:"server"`
22 Queues store.Queues `json:"queues"`
23 }
24 if msg, ok := s.runControlInto(viewer, []string{"dashboard"}, &d); !ok {
25 http.Error(w, msg, http.StatusInternalServerError)
26 return
27 }
28 s.render(w, "admin.html", struct {
29 basePage
30 Tab string
31 Commit string
32 Queues store.Queues
33 }{s.baseFor(viewer), "admin", d.Server.Commit, d.Queues})
34}
internal/httpd/page.go +2
@@ -41,6 +41,7 @@ type basePage struct {
4141 Site string
4242 Host string
4343 Viewer string
44 Admin bool // the viewer is an instance admin: the rail shows /admin
4445 Rail rail
4546}
4647
@@ -61,6 +62,7 @@ func (s *Server) baseFor(viewer store.User) basePage {
6162 return b
6263 }
6364 b.Viewer = viewer.Username
65 b.Admin = viewer.IsAdmin
6466 b.Rail = s.railFor(viewer)
6567 return b
6668}
internal/httpd/routes.go +1
@@ -91,6 +91,7 @@ func (s *Server) Routes() []Route {
9191 Handler: s.checkOrigin(s.logout)},
9292 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
9393 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)},
94 Route{Method: "GET", Pattern: "/admin", Handler: s.requireUser(s.adminPage)},
9495 Route{Method: "POST", Pattern: "/{owner}", Mutating: true,
9596 Handler: s.checkOrigin(s.requireUser(s.orgSubmit))},
9697 Route{Method: "POST", Pattern: "/settings", Mutating: true,
internal/store/queues.go added +223
@@ -0,0 +1,223 @@
1package store
2
3import "fmt"
4
5// Queues is the state of every background worker, for the instance admin:
6// what is waiting, what is retrying, and what has given up. Item lists are
7// capped so a flood of one kind cannot bury the others.
8type Queues struct {
9 Webhooks QueueWebhooks `json:"webhooks"`
10 Mail QueueMail `json:"mail"`
11 Mirrors QueueMirrors `json:"mirrors"`
12 Builds QueueBuilds `json:"builds"`
13 Deps QueueDeps `json:"deps"`
14}
15
16type QueueWebhooks struct {
17 Pending int64 `json:"pending"`
18 Retrying int64 `json:"retrying"` // pending with at least one failed attempt
19 Failed int64 `json:"failed"` // dead-lettered
20 OldestPending string `json:"oldest_pending,omitempty"`
21 Items []QueueDeliveryRow `json:"items"` // retrying and dead-lettered, newest first
22}
23
24type QueueDeliveryRow struct {
25 ID int64 `json:"id"`
26 Repo string `json:"repo"`
27 URL string `json:"url"`
28 Attempts int64 `json:"attempts"`
29 Status int64 `json:"last_status,omitempty"`
30 LastError string `json:"last_error,omitempty"`
31 FailedAt string `json:"failed_at,omitempty"`
32 CreatedAt string `json:"created_at"`
33}
34
35type QueueMail struct {
36 Pending int64 `json:"pending"`
37 Retrying int64 `json:"retrying"`
38 Failed int64 `json:"failed"`
39 OldestPending string `json:"oldest_pending,omitempty"`
40 Items []QueueMailRow `json:"items"`
41}
42
43type QueueMailRow struct {
44 ID int64 `json:"id"`
45 Recipient string `json:"recipient"`
46 Subject string `json:"subject"`
47 Attempts int64 `json:"attempts"`
48 LastError string `json:"last_error,omitempty"`
49 FailedAt string `json:"failed_at,omitempty"`
50 CreatedAt string `json:"created_at"`
51}
52
53type QueueMirrors struct {
54 Dirty int64 `json:"dirty"` // waiting for a sync
55 Errors int64 `json:"errors"`
56 Items []QueueMirrorRow `json:"items"` // the ones whose last sync failed
57}
58
59type QueueMirrorRow struct {
60 ID int64 `json:"id"`
61 Repo string `json:"repo"`
62 Direction string `json:"direction"`
63 URL string `json:"url"`
64 LastSync string `json:"last_sync,omitempty"`
65 LastError string `json:"last_error"`
66}
67
68type QueueBuilds struct {
69 Pending int64 `json:"pending"`
70 Running int64 `json:"running"`
71 OldestPending string `json:"oldest_pending,omitempty"`
72 Items []QueueBuildRow `json:"items"` // running builds, oldest first
73}
74
75type QueueBuildRow struct {
76 Repo string `json:"repo"`
77 Number int64 `json:"number"`
78 Job string `json:"job"`
79 StartedAt string `json:"started_at"`
80}
81
82type QueueDeps struct {
83 Errors int64 `json:"errors"`
84 Items []QueueDepRow `json:"items"`
85}
86
87type QueueDepRow struct {
88 Repo string `json:"repo"`
89 LastCheck string `json:"last_check,omitempty"`
90 LastError string `json:"last_error"`
91}
92
93const queueItemCap = 20
94
95const repoPathExpr = `COALESCE(u.username, o.name) || '/' || r.name`
96
97// repoJoin joins repos and their owner for the path expression; the
98// argument is the column holding the repo id.
99func repoJoin(col string) string {
100 return fmt.Sprintf(` JOIN repos r ON r.id = %s
101 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
102 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id`, col)
103}
104
105// QueueStatus reads every worker queue. Read-only; safe on a live daemon.
106func (s *Store) QueueStatus() (Queues, error) {
107 q := Queues{
108 Webhooks: QueueWebhooks{Items: []QueueDeliveryRow{}},
109 Mail: QueueMail{Items: []QueueMailRow{}},
110 Mirrors: QueueMirrors{Items: []QueueMirrorRow{}},
111 Builds: QueueBuilds{Items: []QueueBuildRow{}},
112 Deps: QueueDeps{Items: []QueueDepRow{}},
113 }
114
115 if err := s.DB.QueryRow(`SELECT
116 COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL),
117 COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL AND attempts > 0),
118 COUNT(*) FILTER (WHERE failed_at IS NOT NULL),
119 COALESCE(MIN(created_at) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL), '')
120 FROM webhook_deliveries`).Scan(&q.Webhooks.Pending, &q.Webhooks.Retrying, &q.Webhooks.Failed, &q.Webhooks.OldestPending); err != nil {
121 return q, err
122 }
123 if err := s.queryEach(`SELECT d.id, `+repoPathExpr+`, w.url, d.attempts, COALESCE(d.last_status, 0),
124 COALESCE(d.last_error, ''), COALESCE(d.failed_at, ''), d.created_at
125 FROM webhook_deliveries d JOIN webhooks w ON w.id = d.webhook_id`+repoJoin("w.repo_id")+`
126 WHERE d.delivered_at IS NULL AND (d.failed_at IS NOT NULL OR d.attempts > 0)
127 ORDER BY d.id DESC LIMIT ?`, func(sc scanner) error {
128 var d QueueDeliveryRow
129 if err := sc.Scan(&d.ID, &d.Repo, &d.URL, &d.Attempts, &d.Status, &d.LastError, &d.FailedAt, &d.CreatedAt); err != nil {
130 return err
131 }
132 q.Webhooks.Items = append(q.Webhooks.Items, d)
133 return nil
134 }); err != nil {
135 return q, err
136 }
137
138 if err := s.DB.QueryRow(`SELECT
139 COUNT(*) FILTER (WHERE sent_at IS NULL AND failed_at IS NULL),
140 COUNT(*) FILTER (WHERE sent_at IS NULL AND failed_at IS NULL AND attempts > 0),
141 COUNT(*) FILTER (WHERE failed_at IS NOT NULL),
142 COALESCE(MIN(created_at) FILTER (WHERE sent_at IS NULL AND failed_at IS NULL), '')
143 FROM notifications`).Scan(&q.Mail.Pending, &q.Mail.Retrying, &q.Mail.Failed, &q.Mail.OldestPending); err != nil {
144 return q, err
145 }
146 if err := s.queryEach(`SELECT id, recipient, subject, attempts, COALESCE(last_error, ''), COALESCE(failed_at, ''), created_at
147 FROM notifications WHERE sent_at IS NULL AND (failed_at IS NOT NULL OR attempts > 0)
148 ORDER BY id DESC LIMIT ?`, func(sc scanner) error {
149 var m QueueMailRow
150 if err := sc.Scan(&m.ID, &m.Recipient, &m.Subject, &m.Attempts, &m.LastError, &m.FailedAt, &m.CreatedAt); err != nil {
151 return err
152 }
153 q.Mail.Items = append(q.Mail.Items, m)
154 return nil
155 }); err != nil {
156 return q, err
157 }
158
159 if err := s.DB.QueryRow(`SELECT COUNT(*) FILTER (WHERE dirty = 1), COUNT(*) FILTER (WHERE last_error != '')
160 FROM mirrors`).Scan(&q.Mirrors.Dirty, &q.Mirrors.Errors); err != nil {
161 return q, err
162 }
163 if err := s.queryEach(`SELECT m.id, `+repoPathExpr+`, m.direction, m.url, m.last_sync, m.last_error
164 FROM mirrors m`+repoJoin("m.repo_id")+` WHERE m.last_error != '' ORDER BY m.id DESC LIMIT ?`, func(sc scanner) error {
165 var m QueueMirrorRow
166 if err := sc.Scan(&m.ID, &m.Repo, &m.Direction, &m.URL, &m.LastSync, &m.LastError); err != nil {
167 return err
168 }
169 q.Mirrors.Items = append(q.Mirrors.Items, m)
170 return nil
171 }); err != nil {
172 return q, err
173 }
174
175 if err := s.DB.QueryRow(`SELECT COUNT(*) FILTER (WHERE status = 'pending'), COUNT(*) FILTER (WHERE status = 'running'),
176 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
178 }
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 var b QueueBuildRow
182 if err := sc.Scan(&b.Repo, &b.Number, &b.Job, &b.StartedAt); err != nil {
183 return err
184 }
185 q.Builds.Items = append(q.Builds.Items, b)
186 return nil
187 }); err != nil {
188 return q, err
189 }
190
191 if err := s.DB.QueryRow(`SELECT COUNT(*) FROM dep_checks WHERE last_error != ''`).Scan(&q.Deps.Errors); err != nil {
192 return q, err
193 }
194 if err := s.queryEach(`SELECT `+repoPathExpr+`, c.last_check, c.last_error
195 FROM dep_checks c`+repoJoin("c.repo_id")+` WHERE c.last_error != '' ORDER BY c.repo_id LIMIT ?`, func(sc scanner) error {
196 var d QueueDepRow
197 if err := sc.Scan(&d.Repo, &d.LastCheck, &d.LastError); err != nil {
198 return err
199 }
200 q.Deps.Items = append(q.Deps.Items, d)
201 return nil
202 }); err != nil {
203 return q, err
204 }
205 return q, nil
206}
207
208type scanner interface{ Scan(...any) error }
209
210// queryEach runs a capped item query and hands each row to fn.
211func (s *Store) queryEach(query string, fn func(scanner) error) error {
212 rows, err := s.DB.Query(query, queueItemCap)
213 if err != nil {
214 return err
215 }
216 defer rows.Close()
217 for rows.Next() {
218 if err := fn(rows); err != nil {
219 return err
220 }
221 }
222 return rows.Err()
223}
internal/web/templates/admin.html added +44
@@ -0,0 +1,44 @@
1{{define "title"}}admin · {{.Site}}{{end}}
2{{define "content"}}
3<h1>Admin</h1>
4<p class="meta">Server build {{.Commit}}. Every background worker's backlog, the same read as <code>gitbay dashboard --json</code> for an admin.</p>
5
6{{with .Queues.Webhooks}}
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>
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>
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}}
12{{end}}
13
14{{with .Queues.Mail}}
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>
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>
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}}
20{{end}}
21
22{{with .Queues.Mirrors}}
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>
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>
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}}
28{{end}}
29
30{{with .Queues.Builds}}
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>
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>
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}}
36{{end}}
37
38{{with .Queues.Deps}}
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>
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}}
43{{end}}
44{{end}}
internal/web/templates/layout.html +1
@@ -41,6 +41,7 @@
4141 </div>
4242 <ul class="raillist railpinned">
4343 {{if .Viewer}}<li><a {{if eq (str . "Tab") "account"}}aria-current="page" {{end}}href="/settings">Settings</a></li>{{end}}
44 {{if .Admin}}<li><a {{if eq (str . "Tab") "admin"}}aria-current="page" {{end}}href="/admin">Admin</a></li>{{end}}
4445 </ul>
4546 <div class="railfoot">
4647 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span><span class="uname">{{.Viewer}}</span></a>