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

merged merged by cmc on 2026-09-02 01:57 UTC · krz/gitbay:admin-queues into main

8 files changed, +440 −0

Layout: unified · split

e2e/adminusers_test.go +104
@@ -6,6 +6,7 @@ import (
6 "path/filepath" 6 "path/filepath"
7 "strings" 7 "strings"
8 "testing" 8 "testing"
9 "time"
9) 10)
10 11
11type adminUserRow struct { 12type adminUserRow struct {
@@ -451,3 +452,106 @@ func TestAuditFilters(t *testing.T) {
451 t.Fatalf("host audit --json --actor:\n%s", out) 452 t.Fatalf("host audit --json --actor:\n%s", out)
452 } 453 }
453} 454}
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 {
70 Activity []feedOut `json:"recent_activity"` 70 Activity []feedOut `json:"recent_activity"`
71 Builds []buildOut `json:"builds"` 71 Builds []buildOut `json:"builds"`
72 Server *serverOut `json:"server,omitempty"` 72 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"`
73 } 76 }
74 d := out{ 77 d := out{
75 Reviews: []dashboardItem{}, Assigned: []dashboardItem{}, MRs: []dashboardItem{}, 78 Reviews: []dashboardItem{}, Assigned: []dashboardItem{}, MRs: []dashboardItem{},
@@ -139,6 +142,11 @@ func runDashboard(c *Ctx, args []string) int {
139 } 142 }
140 if c.User.IsAdmin { 143 if c.User.IsAdmin {
141 d.Server = &serverOut{Commit: buildinfo.String()} 144 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
142 } 150 }
143 151
144 return c.emit(d, func(w io.Writer) { 152 return c.emit(d, func(w io.Writer) {
@@ -169,6 +177,29 @@ func runDashboard(c *Ctx, args []string) int {
169 if d.Server != nil { 177 if d.Server != nil {
170 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit) 178 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit)
171 } 179 }
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 }
172 }) 203 })
173} 204}
174 205
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 {
41 Site string 41 Site string
42 Host string 42 Host string
43 Viewer string 43 Viewer string
44 Admin bool // the viewer is an instance admin: the rail shows /admin
44 Rail rail 45 Rail rail
45} 46}
46 47
@@ -61,6 +62,7 @@ func (s *Server) baseFor(viewer store.User) basePage {
61 return b 62 return b
62 } 63 }
63 b.Viewer = viewer.Username 64 b.Viewer = viewer.Username
65 b.Admin = viewer.IsAdmin
64 b.Rail = s.railFor(viewer) 66 b.Rail = s.railFor(viewer)
65 return b 67 return b
66} 68}
internal/httpd/routes.go +1
@@ -91,6 +91,7 @@ func (s *Server) Routes() []Route {
91 Handler: s.checkOrigin(s.logout)}, 91 Handler: s.checkOrigin(s.logout)},
92 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, 92 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
93 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, 93 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)},
94 Route{Method: "GET", Pattern: "/admin", Handler: s.requireUser(s.adminPage)},
94 Route{Method: "POST", Pattern: "/{owner}", Mutating: true, 95 Route{Method: "POST", Pattern: "/{owner}", Mutating: true,
95 Handler: s.checkOrigin(s.requireUser(s.orgSubmit))}, 96 Handler: s.checkOrigin(s.requireUser(s.orgSubmit))},
96 Route{Method: "POST", Pattern: "/settings", Mutating: true, 97 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 @@
41 </div> 41 </div>
42 <ul class="raillist railpinned"> 42 <ul class="raillist railpinned">
43 {{if .Viewer}}<li><a {{if eq (str . "Tab") "account"}}aria-current="page" {{end}}href="/settings">Settings</a></li>{{end}} 43 {{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}}
44 </ul> 45 </ul>
45 <div class="railfoot"> 46 <div class="railfoot">
46 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span><span class="uname">{{.Viewer}}</span></a> 47 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span><span class="uname">{{.Viewer}}</span></a>