Commit 8c7a8b7eef
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
e2e/adminusers_test.go +104
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "path/filepath" |
| 7 | 7 | "strings" |
| 8 | 8 | "testing" |
| 9 | "time" | |
| 9 | 10 | ) |
| 10 | 11 | |
| 11 | 12 | type adminUserRow struct { |
| @@ -451,3 +452,106 @@ func TestAuditFilters(t *testing.T) { | ||
| 451 | 452 | t.Fatalf("host audit --json --actor:\n%s", out) |
| 452 | 453 | } |
| 453 | 454 | } |
| 455 | ||
| 456 | func 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 | 70 | Activity []feedOut `json:"recent_activity"` |
| 71 | 71 | Builds []buildOut `json:"builds"` |
| 72 | 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 | 77 | d := out{ |
| 75 | 78 | Reviews: []dashboardItem{}, Assigned: []dashboardItem{}, MRs: []dashboardItem{}, |
| @@ -139,6 +142,11 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 139 | 142 | } |
| 140 | 143 | if c.User.IsAdmin { |
| 141 | 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 | 152 | return c.emit(d, func(w io.Writer) { |
| @@ -169,6 +177,29 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 169 | 177 | if d.Server != nil { |
| 170 | 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | func (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 | 41 | Site string |
| 42 | 42 | Host string |
| 43 | 43 | Viewer string |
| 44 | Admin bool // the viewer is an instance admin: the rail shows /admin | |
| 44 | 45 | Rail rail |
| 45 | 46 | } |
| 46 | 47 | |
| @@ -61,6 +62,7 @@ func (s *Server) baseFor(viewer store.User) basePage { | ||
| 61 | 62 | return b |
| 62 | 63 | } |
| 63 | 64 | b.Viewer = viewer.Username |
| 65 | b.Admin = viewer.IsAdmin | |
| 64 | 66 | b.Rail = s.railFor(viewer) |
| 65 | 67 | return b |
| 66 | 68 | } |
internal/httpd/routes.go +1
| @@ -91,6 +91,7 @@ func (s *Server) Routes() []Route { | ||
| 91 | 91 | Handler: s.checkOrigin(s.logout)}, |
| 92 | 92 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 93 | 93 | Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, |
| 94 | Route{Method: "GET", Pattern: "/admin", Handler: s.requireUser(s.adminPage)}, | |
| 94 | 95 | Route{Method: "POST", Pattern: "/{owner}", Mutating: true, |
| 95 | 96 | Handler: s.checkOrigin(s.requireUser(s.orgSubmit))}, |
| 96 | 97 | Route{Method: "POST", Pattern: "/settings", Mutating: true, |
internal/store/queues.go added +223
| @@ -0,0 +1,223 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "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. | |
| 8 | type 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 | ||
| 16 | type 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 | ||
| 24 | type 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 | ||
| 35 | type 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 | ||
| 43 | type 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 | ||
| 53 | type 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 | ||
| 59 | type 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 | ||
| 68 | type 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 | ||
| 75 | type 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 | ||
| 82 | type QueueDeps struct { | |
| 83 | Errors int64 `json:"errors"` | |
| 84 | Items []QueueDepRow `json:"items"` | |
| 85 | } | |
| 86 | ||
| 87 | type QueueDepRow struct { | |
| 88 | Repo string `json:"repo"` | |
| 89 | LastCheck string `json:"last_check,omitempty"` | |
| 90 | LastError string `json:"last_error"` | |
| 91 | } | |
| 92 | ||
| 93 | const queueItemCap = 20 | |
| 94 | ||
| 95 | const 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. | |
| 99 | func 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. | |
| 106 | func (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 | ||
| 208 | type scanner interface{ Scan(...any) error } | |
| 209 | ||
| 210 | // queryEach runs a capped item query and hands each row to fn. | |
| 211 | func (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 | 41 | </div> |
| 42 | 42 | <ul class="raillist railpinned"> |
| 43 | 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 | 45 | </ul> |
| 45 | 46 | <div class="railfoot"> |
| 46 | 47 | {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span><span class="uname">{{.Viewer}}</span></a> |