Commit 7f6601266e

7f6601266e978b4782642424c80db6c01f06c96f

parent: 5e5b5210b9

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 04:36 UTC

control: admin commands as screens

admin user list (pending accounts lead with ●) and show, admin repo
list, admin runners, admin stats (the 20 largest repositories, then
admin repo list), admin mr prune, and admin mail inbound check. The
enabled inbound check needs a live mailbox and is covered by build and
vet only.

Ref #319

Layout: unified · split

internal/control/admin.go +142 −5
@@ -159,7 +159,7 @@ func runAdminUserList(c *Ctx, args []string) int {
159159 for _, u := range users {
160160 ds = append(ds, adminUserRow(u))
161161 }
162 return c.emitPage(p, ds, next, func(w io.Writer) {
162 return c.emitPageView(p, ds, next, func(w io.Writer) {
163163 tb := c.table(w, "USERNAME", "STATE", "ADMIN", "CREATED", "LAST SEEN")
164164 for _, d := range ds {
165165 mark := ""
@@ -169,6 +169,25 @@ func runAdminUserList(c *Ctx, args []string) int {
169169 tb.row(cRef(d.Username), cState(d.State), cText(mark), cAge(d.CreatedAt), cAge(d.LastSeen))
170170 }
171171 tb.flush()
172 }, func() screen {
173 rows := make([]row, len(ds))
174 for i, d := range ds {
175 lead := cGlyph("")
176 if d.State == "pending" {
177 lead = cYou()
178 }
179 admin, seen := "", ""
180 if d.Admin {
181 admin = "admin"
182 }
183 if d.LastSeen != "" {
184 seen = "seen " + relAge(d.LastSeen, termNow())
185 }
186 rows[i] = rowOf(cRef(d.Username), lead, cState(d.State), cMeta(admin, seen))
187 }
188 return listScreen("Accounts", rows,
189 action{"Filter", []string{"admin", "user", "list", "--state", "pending"}},
190 )
172191 })
173192}
174193
@@ -280,7 +299,7 @@ func runAdminUserShow(c *Ctx, args []string) int {
280299 return c.fail(protocol.ExitFailure, "%v", err)
281300 }
282301
283 return c.emit(d, func(w io.Writer) {
302 return c.emitView(d, func(w io.Writer) {
284303 admin := ""
285304 if d.Admin {
286305 admin = "yes"
@@ -346,6 +365,66 @@ func runAdminUserShow(c *Ctx, args []string) int {
346365 }
347366 tt.flush()
348367 }
368 }, func() screen {
369 s := screen{}
370 user := []cell{cRef(d.Username), cState(d.State)}
371 if d.Admin {
372 user = append(user, cMeta("admin"))
373 }
374 seen := "never seen"
375 if d.LastSeen != "" {
376 seen = "seen " + relAge(d.LastSeen, termNow())
377 }
378 s.fields = []field{
379 {"User", user},
380 {"Seen", []cell{cText(seen), cMeta("created " + relAge(d.CreatedAt, termNow()))}},
381 {"Repos", []cell{cText(strconv.FormatInt(d.Repos, 10))}},
382 {"Sessions", []cell{cText(strconv.FormatInt(d.WebSessions, 10))}},
383 }
384 keys := section{title: "Keys", n: len(d.Keys)}
385 for _, k := range d.Keys {
386 keys.rows = append(keys.rows, rowOf(cRef(k.Fingerprint), cState(k.Scope), cMeta(k.Algo, "used "+relAge(k.LastUsedAt, termNow()))))
387 }
388 emails := section{title: "Emails", n: len(d.Emails)}
389 for _, e := range d.Emails {
390 state, primary := "unverified", ""
391 if e.Verified {
392 state = "verified"
393 }
394 if e.Primary {
395 primary = "primary"
396 }
397 emails.rows = append(emails.rows, rowOf(cRef(e.Address), cState(state), cMeta(primary, e.VerifiedBy)))
398 }
399 pgp := section{title: "PGP keys", n: len(d.PGPKeys)}
400 for _, k := range d.PGPKeys {
401 pgp.rows = append(pgp.rows, rowOf(cRef(k.Fingerprint)))
402 }
403 orgs := section{title: "Orgs", n: len(d.Orgs)}
404 for _, o := range d.Orgs {
405 orgs.rows = append(orgs.rows, rowOf(cLink(o.Org, c.siteURL(o.Org)), cState(o.Role)))
406 }
407 tokens := section{title: "API tokens", n: len(d.APITokens)}
408 for _, tk := range d.APITokens {
409 used := ""
410 if tk.LastUsedAt != nil {
411 used = "used " + relAge(tk.LastUsedAt.UTC().Format(time.RFC3339Nano), termNow())
412 }
413 tokens.rows = append(tokens.rows, rowOf(cRef(tk.Name), cState(tk.Scope), cMeta(used)))
414 }
415 s.sections = []section{keys, emails, pgp, orgs, tokens}
416 role, state := "promote", "disable"
417 if d.Admin {
418 role = "demote"
419 }
420 if d.State == "disabled" {
421 state = "enable"
422 }
423 s.actions = []action{
424 {"Manage", []string{"admin", "user", role, d.Username}},
425 {"Manage", []string{"admin", "user", state, d.Username}},
426 }
427 return s
349428 })
350429}
351430
@@ -439,7 +518,7 @@ func runAdminRepoList(c *Ctx, args []string) int {
439518 size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
440519 ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size})
441520 }
442 return c.emitPage(p, ds, next, func(w io.Writer) {
521 return c.emitPageView(p, ds, next, func(w io.Writer) {
443522 tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH")
444523 for _, d := range ds {
445524 cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
@@ -449,6 +528,22 @@ func runAdminRepoList(c *Ctx, args []string) int {
449528 tb.row(cells...)
450529 }
451530 tb.flush()
531 }, func() screen {
532 rows := make([]row, len(ds))
533 for i, d := range ds {
534 state := d.Visibility
535 if d.Archived {
536 state += ", archived"
537 }
538 pushed := ""
539 if d.LastPush != "" {
540 pushed = "pushed " + relAge(d.LastPush, termNow())
541 }
542 rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cSize(d.Bytes), cMeta(pushed))
543 }
544 return listScreen("Repositories", rows,
545 action{"Filter", []string{"admin", "repo", "list", "--visibility", "private"}},
546 )
452547 })
453548}
454549
@@ -574,7 +669,7 @@ func runAdminRunners(c *Ctx, args []string) int {
574669 }
575670 }
576671 d := map[string]any{"queue": queue, "runners": runners}
577 return c.emit(d, func(w io.Writer) {
672 return c.emitView(d, func(w io.Writer) {
578673 v := c.view(w)
579674 v.fields(
580675 "pending", fmt.Sprintf("%d", queue.Pending),
@@ -599,6 +694,32 @@ func runAdminRunners(c *Ctx, args []string) int {
599694 tb.row(cText(r.Username), cText(r.Fingerprint), cAge(r.LastSeen), cFlex(scope), cText(held))
600695 }
601696 tb.flush()
697 }, func() screen {
698 s := screen{fields: []field{{"Queue", []cell{
699 cText(fmt.Sprintf("%d pending", queue.Pending)),
700 cMeta(fmt.Sprintf("%d claimed in 24h", queue.Claimed24h), "wait avg "+c.Term.dur(queue.ClaimWaitAvgS),
701 "max "+c.Term.dur(queue.ClaimWaitMaxS), fmt.Sprintf("%d reaped", queue.Reaped24h)),
702 }}}}
703 runnersSec := section{title: "Runners", n: len(runners)}
704 idle := ""
705 for _, r := range runners {
706 scope := r.Scope
707 if scope == "" {
708 scope = "any"
709 }
710 lead, held := cGlyph(""), "idle"
711 if r.BuildNumber != 0 {
712 lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob)
713 } else if idle == "" {
714 idle = r.Fingerprint
715 }
716 runnersSec.rows = append(runnersSec.rows, rowOf(cRef(r.Username), lead, cFlex(scope), cMeta("seen "+relAge(r.LastSeen, termNow()), held)))
717 }
718 s.sections = []section{runnersSec}
719 if idle != "" {
720 s.actions = []action{{"Prune", []string{"admin", "runners", "remove", idle}}}
721 }
722 return s
602723 })
603724}
604725
@@ -687,7 +808,7 @@ func runAdminMRPrune(c *Ctx, args []string) int {
687808 if err := gitutil.PruneNow(dir); err != nil {
688809 return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err)
689810 }
690 return c.emit(rows, func(w io.Writer) {
811 return c.emitView(rows, func(w io.Writer) {
691812 tb := c.table(w, "!", "HEAD")
692813 for _, r := range rows {
693814 if r.Head == "" {
@@ -697,5 +818,21 @@ func runAdminMRPrune(c *Ctx, args []string) int {
697818 tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cRef(r.Head))
698819 }
699820 tb.flush()
821 }, func() screen {
822 rs := make([]row, len(rows))
823 for i, r := range rows {
824 n := strconv.FormatInt(r.Number, 10)
825 ref := cLink("!"+n, c.siteURL(repo.Path(), "mrs", n))
826 if r.Head == "" {
827 rs[i] = rowOf(ref, cGlyph("skipped"), cMeta("already gone"))
828 continue
829 }
830 rs[i] = rowOf(ref, cGlyph("ok"), cRef(fmt.Sprintf("%.10s", r.Head)))
831 }
832 s := listScreen("Pruned", rs)
833 if len(rows) > 0 {
834 s.actions = []action{{"Read", []string{"mr", "show", repo.Path(), strconv.FormatInt(rows[0].Number, 10)}}}
835 }
836 return s
700837 })
701838}
internal/control/adminhost.go +27 −1
@@ -1,10 +1,12 @@
11package control
22
33import (
4 "cmp"
45 "errors"
56 "fmt"
67 "io"
78 "os"
9 "slices"
810
911 "golang.org/x/crypto/ssh"
1012
@@ -324,7 +326,7 @@ func runAdminStats(c *Ctx, args []string) int {
324326 if fi, err := os.Stat(c.Cfg.Server.Root + "/gitbay.db"); err == nil {
325327 d.DBBytes = fi.Size()
326328 }
327 return c.emit(d, func(w io.Writer) {
329 return c.emitView(d, func(w io.Writer) {
328330 v := c.view(w)
329331 v.fields(
330332 "users", fmt.Sprintf("%d", counts.Users),
@@ -344,6 +346,30 @@ func runAdminStats(c *Ctx, args []string) int {
344346 }
345347 tb.flush()
346348 }
349 }, func() screen {
350 count := func(all, open int64) []cell {
351 return []cell{cText(fmt.Sprint(all)), cMeta(fmt.Sprintf("%d open", open))}
352 }
353 s := screen{fields: []field{
354 {"Users", []cell{cText(fmt.Sprint(counts.Users))}},
355 {"Orgs", []cell{cText(fmt.Sprint(counts.Orgs))}},
356 {"Repos", []cell{cText(fmt.Sprint(counts.Repos))}},
357 {"Issues", count(counts.Issues, counts.OpenIssues)},
358 {"MRs", count(counts.MRs, counts.OpenMRs)},
359 {"Disk", []cell{cSize(d.DBBytes), cMeta("database", "repositories "+humanBytes(d.RepoBytes), "lfs "+humanBytes(d.LFSBytes))}},
360 }}
361 repos := slices.Clone(d.Repos)
362 slices.SortStableFunc(repos, func(a, b repoDisk) int { return cmp.Compare(b.Bytes, a.Bytes) })
363 top := section{title: "Repositories", n: len(repos), more: []string{"admin", "repo", "list"}}
364 for _, r := range repos[:min(20, len(repos))] {
365 top.rows = append(top.rows, rowOf(cLink(r.Path, c.siteURL(r.Path)), cSize(r.Bytes)))
366 }
367 s.sections = []section{top}
368 s.actions = []action{
369 {"Admin", []string{"admin", "user", "list"}},
370 {"Admin", []string{"admin", "runners"}},
371 }
372 return s
347373 })
348374}
349375
internal/control/adminmail.go +20 −2
@@ -43,8 +43,10 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
4343 Warning string `json:"warning,omitempty"`
4444 }
4545 if !in.Enabled {
46 return c.emit(out{}, func(w io.Writer) {
46 return c.emitView(out{}, func(w io.Writer) {
4747 fmt.Fprintln(w, "inbound mail is off ([mail.inbound] enabled = false)")
48 }, func() screen {
49 return screen{fields: []field{{"Inbound", []cell{cText("off")}}}}
4850 })
4951 }
5052 cl, n, err := imapc.Open(in, true, 30*time.Second)
@@ -62,7 +64,7 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
6264 d.Warning = unauthenticatedWarning
6365 fmt.Fprintln(c.Stderr, "warning: "+d.Warning)
6466 }
65 return c.emit(d, func(w io.Writer) {
67 return c.emitView(d, func(w io.Writer) {
6668 c.view(w).fields(
6769 "server", d.Server,
6870 "mailbox", d.Mailbox,
@@ -71,5 +73,21 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
7173 "require_dkim", fmt.Sprintf("%t", d.RequireDKIM),
7274 "trusted_authserv_id", d.TrustedAuthservID,
7375 )
76 }, func() screen {
77 auth := cMark("✓ dkim", sgrGreen)
78 if d.Warning != "" {
79 auth = cMark("✗ "+d.Warning, sgrRed)
80 } else if !d.RequireDKIM {
81 auth = cMeta("dkim not required")
82 }
83 s := screen{fields: []field{
84 {"Inbound", []cell{cText(d.Server), cMeta(d.Mailbox)}},
85 {"Messages", []cell{cText(fmt.Sprint(d.Messages)), cMeta(fmt.Sprintf("%d unseen", d.Unseen))}},
86 {"Auth", []cell{auth}},
87 }}
88 if d.TrustedAuthservID != "" {
89 s.fields = append(s.fields, field{"Authserv", []cell{cText(d.TrustedAuthservID)}})
90 }
91 return s
7492 })
7593}
internal/control/stage3admin_test.go added +70
@@ -0,0 +1,70 @@
1package control
2
3import (
4 "regexp"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/store"
9)
10
11var diskBytes = regexp.MustCompile(`\t\d{4,}\t|\d+\.\d KiB`)
12
13// adminFixture is the work fixture with alice as an instance admin, a
14// second account, and a closed merge request to prune.
15func adminFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) {
16 t.Helper()
17 st, repo, u, root, _ := workFixture(t)
18 if err := st.SetUserAdmin(u.ID, true); err != nil {
19 t.Fatal(err)
20 }
21 u.IsAdmin = true
22 dispatchIn(t, st, u, root, "", "admin", "user", "create", "bob")
23 dispatchIn(t, st, u, root, "", "mr", "close", repo.Path(), "1")
24 return st, repo, u, root
25}
26
27func TestAdminPlainPinned(t *testing.T) {
28 st, repo, u, root := adminFixture(t)
29 for name, argv := range map[string][]string{
30 "admin-user-list": {"admin", "user", "list"},
31 "admin-user-show": {"admin", "user", "show", "alice"},
32 "admin-runners": {"admin", "runners"},
33 "admin-repo-list": {"admin", "repo", "list"},
34 "admin-stats": {"admin", "stats"},
35 "admin-mail-inbound-check": {"admin", "mail", "inbound", "check"},
36 } {
37 got := dispatchIn(t, st, u, root, "", argv...)
38 // A repository's size on disk moves by a few bytes between runs.
39 got = diskBytes.ReplaceAllString(got, "<size>")
40 pinPlain(t, name, got)
41 }
42 pinPlain(t, "admin-mr-prune", dispatchIn(t, st, u, root, "", "admin", "mr", "prune", repo.Path(), "1", "--yes"))
43}
44
45func TestAdminScreens(t *testing.T) {
46 st, repo, u, root := adminFixture(t)
47 for _, tc := range []struct {
48 argv []string
49 want []string
50 }{
51 {[]string{"admin", "user", "list"}, []string{"Accounts (3)\n", "alice", "bob", "admin"}},
52 {[]string{"admin", "user", "show", "alice"}, []string{"User:", "alice", "active", "gitbay admin user demote alice"}},
53 {[]string{"admin", "runners"}, []string{"Queue:", "0 pending"}},
54 {[]string{"admin", "repo", "list"}, []string{"Repositories (1)\n", "alice/app"}},
55 {[]string{"admin", "stats"}, []string{"Users:", "2", "Repos:", "Disk:"}},
56 {[]string{"admin", "mail", "inbound", "check"}, []string{"Inbound: off\n"}},
57 {[]string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}, []string{"Pruned (1)\n", "!1", "already gone"}},
58 } {
59 out := atTerminalIn(t, st, u, root, tc.argv...)
60 for _, w := range tc.want {
61 if !strings.Contains(out, w) {
62 t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out)
63 }
64 }
65 if strings.Contains(tc.argv[1], "mail") || tc.argv[1] == "runners" {
66 continue
67 }
68 checkLegend(t, out)
69 }
70}
internal/control/testdata/plain/admin-mail-inbound-check.txt added +1
@@ -0,0 +1 @@
1inbound mail is off ([mail.inbound] enabled = false)
internal/control/testdata/plain/admin-mr-prune.txt added +1
@@ -0,0 +1 @@
1!1 already gone
internal/control/testdata/plain/admin-repo-list.txt added +1
@@ -0,0 +1 @@
1alice/app public<size><time>
internal/control/testdata/plain/admin-runners.txt added +5
@@ -0,0 +1,5 @@
1 pending 0
2 claimed 24h 0
3 wait avg 0s
4 wait max 0s
5 reaped 24h 0
internal/control/testdata/plain/admin-stats.txt added +11
@@ -0,0 +1,11 @@
1 users 3
2 orgs 0
3 repos 1
4 issues 1 (1 open)
5 MRs 1 (0 open)
6 database 0 B
7 repositories <size>
8 lfs 0 B
9
10repos:
11alice/app <size>
internal/control/testdata/plain/admin-user-list.txt added +3
@@ -0,0 +1,3 @@
1alice active admin <time>
2bob active <time>
3gitbay-bot active <time>
internal/control/testdata/plain/admin-user-show.txt added +6
@@ -0,0 +1,6 @@
1alice active
2
3 admin yes
4 created <time>
5 repos 1
6 web sessions 0