Commit d1e2c3117e

d1e2c3117e65ba30b901687b615a9b82b7240185

parent: d35194d66f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:43 UTC

control: dashboard as a screen

Server and queue detail leave the terminal dashboard for admin stats;
a background failure is one Problems line. --json is unchanged.

Ref #318

Layout: unified · split

internal/control/dashboard.go +142 −110
@@ -95,47 +95,7 @@ type DashboardOut struct {
95 95
96// dashboardActivity is how many feed lines the dashboard shows at a 96// dashboardActivity is how many feed lines the dashboard shows at a
97// terminal. 97// terminal.
98const dashboardActivity = 8 98const dashboardActivity = 5
99
100// needsYou is the dashboard's first line at a terminal: what waits on
101// the viewer, in yellow, or a dim line saying nothing does.
102func (t Term) needsYou(d DashboardOut) string {
103 var parts []string
104 add := func(n int, one, many string) {
105 switch {
106 case n == 1:
107 parts = append(parts, "1 "+one)
108 case n > 1:
109 parts = append(parts, fmt.Sprintf("%d %s", n, many))
110 }
111 }
112 add(len(d.Reviews), "review requested", "reviews requested")
113 add(len(d.Assigned), "assigned issue", "assigned issues")
114 add(failingBuilds(d.Builds), "failing build", "failing builds")
115 add(d.Unread, "unread notification", "unread notifications")
116 if len(parts) == 0 {
117 return t.paint(sgrDim, "Nothing waits on you.")
118 }
119 return t.paint(sgrBold+sgrYellow, "Needs you: "+strings.Join(parts, ", "))
120}
121
122// failingBuilds counts the jobs whose latest build failed: a failure a
123// later build of the same job and ref has already replaced is not one.
124func failingBuilds(builds []DashboardBuild) int {
125 seen := map[string]bool{}
126 n := 0
127 for _, b := range builds {
128 key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
129 if seen[key] {
130 continue
131 }
132 seen[key] = true
133 if b.Status == "failure" {
134 n++
135 }
136 }
137 return n
138}
139 99
140func runDashboard(c *Ctx, args []string) int { 100func runDashboard(c *Ctx, args []string) int {
141 if len(args) != 0 { 101 if len(args) != 0 {
@@ -220,48 +180,25 @@ func runDashboard(c *Ctx, args []string) int {
220 d.Queues = &q 180 d.Queues = &q
221 } 181 }
222 182
223 return c.emit(d, func(w io.Writer) { 183 lines := FeedLines(events)
224 // At a terminal sections are separated by a blank line, and an 184 return c.emitView(d, func(w io.Writer) {
225 // empty one is left out. 185 heading := func(title string) { fmt.Fprintln(w, title) }
226 wrote := false 186 section := func(title string, rows [][]cell) {
227 heading := func(title string) {
228 if c.Term.Cols == 0 {
229 fmt.Fprintln(w, title)
230 return
231 }
232 if wrote {
233 fmt.Fprintln(w)
234 }
235 wrote = true
236 fmt.Fprintln(w, c.Term.heading(title))
237 }
238 section := func(title string, header []string, rows [][]cell) {
239 if c.Term.Cols > 0 && len(rows) == 0 {
240 return
241 }
242 heading(title) 187 heading(title)
243 if len(rows) == 0 { 188 if len(rows) == 0 {
244 fmt.Fprintln(w, " none") 189 fmt.Fprintln(w, " none")
245 return 190 return
246 } 191 }
247 if c.Term.Cols == 0 { 192 for _, r := range rows {
248 for _, r := range rows { 193 parts := make([]string, len(r))
249 parts := make([]string, len(r)) 194 for i, cl := range r {
250 for i, cl := range r { 195 parts[i] = cl.s
251 parts[i] = cl.s 196 if cl.kind == kindAge {
252 if cl.kind == kindAge { 197 parts[i] = stamp(cl.s)
253 parts[i] = stamp(cl.s)
254 }
255 } 198 }
256 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
257 } 199 }
258 return 200 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
259 }
260 tb := c.table(w, header...)
261 for _, r := range rows {
262 tb.row(r...)
263 } 201 }
264 tb.flush()
265 } 202 }
266 itemRows := func(items []DashboardItem, marker string) [][]cell { 203 itemRows := func(items []DashboardItem, marker string) [][]cell {
267 rows := make([][]cell, len(items)) 204 rows := make([][]cell, len(items))
@@ -276,17 +213,13 @@ func runDashboard(c *Ctx, args []string) int {
276 return rows 213 return rows
277 } 214 }
278 215
279 if c.Term.Cols > 0 { 216 if d.Unread > 0 {
280 fmt.Fprintln(w, c.Term.needsYou(d))
281 wrote = true
282 } else if d.Unread > 0 {
283 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread) 217 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
284 } 218 }
285 itemHeader := []string{"REF", "TITLE", "AUTHOR"} 219 section("waiting on your review:", itemRows(d.Reviews, "!"))
286 section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!")) 220 section("assigned to you:", itemRows(d.Assigned, "#"))
287 section("assigned to you:", itemHeader, itemRows(d.Assigned, "#")) 221 section("open merge requests:", itemRows(d.MRs, "!"))
288 section("open merge requests:", itemHeader, itemRows(d.MRs, "!")) 222 section("open issues:", itemRows(d.Issues, "#"))
289 section("open issues:", itemHeader, itemRows(d.Issues, "#"))
290 for _, q := range d.Queries { 223 for _, q := range d.Queries {
291 rows := make([][]cell, 0, len(q.Items)) 224 rows := make([][]cell, 0, len(q.Items))
292 for _, it := range q.Items { 225 for _, it := range q.Items {
@@ -296,7 +229,7 @@ func runDashboard(c *Ctx, args []string) int {
296 if q.Error != "" { 229 if q.Error != "" {
297 title = fmt.Sprintf("query %s: %s", q.Name, q.Error) 230 title = fmt.Sprintf("query %s: %s", q.Name, q.Error)
298 } 231 }
299 section(title, itemHeader, rows) 232 section(title, rows)
300 } 233 }
301 234
302 pinnedRows := make([][]cell, len(d.Pinned)) 235 pinnedRows := make([][]cell, len(d.Pinned))
@@ -307,32 +240,19 @@ func runDashboard(c *Ctx, args []string) int {
307 } 240 }
308 pinnedRows[i] = cells 241 pinnedRows[i] = cells
309 } 242 }
310 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows) 243 section("pinned:", pinnedRows)
311 244
312 lines := FeedLines(events) 245 activityRows := make([][]cell, len(lines))
313 if c.Term.Cols > 0 { 246 for i, l := range lines {
314 // The feed has its own command; the dashboard shows the start. 247 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
315 rows := make([][]cell, 0, dashboardActivity)
316 for _, l := range lines[:min(len(lines), dashboardActivity)] {
317 rows = append(rows, l.termCells(c))
318 }
319 section("recent activity:", feedHeader, rows)
320 if len(lines) > dashboardActivity {
321 fmt.Fprintln(w, c.Term.paint(sgrDim, "more: gitbay feed"))
322 }
323 } else {
324 activityRows := make([][]cell, len(lines))
325 for i, l := range lines {
326 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
327 }
328 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
329 } 248 }
249 section("recent activity:", activityRows)
330 250
331 buildRows := make([][]cell, len(d.Builds)) 251 buildRows := make([][]cell, len(d.Builds))
332 for i, b := range d.Builds { 252 for i, b := range d.Builds {
333 buildRows[i] = []cell{cLink(b.Repo, c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)} 253 buildRows[i] = []cell{cLink(b.Repo, c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)}
334 } 254 }
335 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows) 255 section("builds:", buildRows)
336 256
337 if d.Server != nil { 257 if d.Server != nil {
338 heading("server:") 258 heading("server:")
@@ -377,11 +297,7 @@ func runDashboard(c *Ctx, args []string) int {
377 if it.Status == "pending" { 297 if it.Status == "pending" {
378 since = it.CreatedAt 298 since = it.CreatedAt
379 } 299 }
380 if c.Term.Cols == 0 { 300 since = stamp(since)
381 since = stamp(since)
382 } else {
383 since = relAge(since, termNow())
384 }
385 tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since))) 301 tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since)))
386 } 302 }
387 tbq.flush() 303 tbq.flush()
@@ -393,7 +309,7 @@ func runDashboard(c *Ctx, args []string) int {
393 } 309 }
394 tde.flush() 310 tde.flush()
395 } 311 }
396 }) 312 }, func() screen { return dashboardScreen(c, d, lines) })
397} 313}
398 314
399// feedDefaultLimit caps a bare `feed` call; pagination reaches further 315// feedDefaultLimit caps a bare `feed` call; pagination reaches further
@@ -457,3 +373,119 @@ func runFeed(c *Ctx, args []string) int {
457 tb.flush() 373 tb.flush()
458 }) 374 })
459} 375}
376
377// dashboardScreen is dashboard at a terminal: what waits on the viewer
378// first, then open merge requests, failed builds, a few lines of
379// activity and the pinned repositories. The operator's queues are
380// admin stats'; a background failure shows as one header line.
381func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen {
382 var s screen
383 s.fields = append(s.fields, field{"User", []cell{cText(c.User.Username)}})
384 if host := c.Cfg.SiteHost(); host != "" {
385 s.fields = append(s.fields, field{"Instance", []cell{cText(host)}})
386 }
387 if q := d.Queues; q != nil {
388 if bad := q.Webhooks.Failed + q.Mail.Failed + q.Push.Failed + q.Mirrors.Errors + q.Deps.Errors; bad > 0 {
389 s.fields = append(s.fields, field{"Problems", []cell{cGlyph("failed"), cText(fmt.Sprintf("%d failing in the background", bad))}})
390 }
391 }
392 if d.Unread > 0 {
393 s.fields = append(s.fields, field{"Inbox", []cell{cYou(), cText(fmt.Sprintf("%d unread", d.Unread))}})
394 }
395
396 item := func(it DashboardItem, marker, page string, you bool) row {
397 lead := cell{kind: kindGlyph}
398 if you {
399 lead = cYou()
400 }
401 ref := cLink(fmt.Sprintf("%s%s%d", it.Repo, marker, it.Number), c.siteURL(it.Repo, page, strconv.FormatInt(it.Number, 10)))
402 return rowOf(ref, lead, cFlex(it.Title), cMeta(it.Author))
403 }
404 items := func(title string, its []DashboardItem, marker, page string, you bool) section {
405 sec := section{title: title, n: len(its)}
406 for _, it := range its {
407 sec.rows = append(sec.rows, item(it, marker, page, you))
408 }
409 return sec
410 }
411 s.sections = append(s.sections,
412 items("Review requested", d.Reviews, "!", "mrs", true),
413 items("Assigned issues", d.Assigned, "#", "issues", true),
414 items("Open merge requests", d.MRs, "!", "mrs", false),
415 )
416 for _, q := range d.Queries {
417 sec := section{title: q.Name, n: q.Count, more: []string{"query", "run", q.Name}}
418 if q.Error != "" {
419 sec.note, sec.empty = q.Error, true
420 }
421 for _, it := range q.Items {
422 sec.rows = append(sec.rows, rowOf(cRef(it.Ref()), cFlex(it.Title), cMeta(it.Author)))
423 }
424 s.sections = append(s.sections, sec)
425 }
426
427 // A job's latest build is the one that counts: a failure a later
428 // build of the same job and ref has replaced is not shown.
429 failed := section{title: "Failed builds"}
430 passed := 0
431 seen := map[string]bool{}
432 var firstFailed *DashboardBuild
433 for i, b := range d.Builds {
434 key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
435 if seen[key] {
436 continue
437 }
438 seen[key] = true
439 switch b.Status {
440 case "success":
441 passed++
442 case "failure", "error":
443 n := strconv.FormatInt(b.Number, 10)
444 failed.n++
445 failed.rows = append(failed.rows, rowOf(cLink(n, c.siteURL(b.Repo, "builds", n)), cGlyph(b.Status),
446 cFlex(b.Job+" "+b.Ref), cMeta(b.Repo, relAge(b.CreatedAt, termNow()))))
447 if firstFailed == nil {
448 firstFailed = &d.Builds[i]
449 }
450 }
451 }
452 s.sections = append(s.sections, failed)
453
454 activity := section{title: "Recent activity", n: len(lines), more: []string{"feed"}}
455 for _, l := range lines[:min(len(lines), dashboardActivity)] {
456 activity.rows = append(activity.rows, rowOf(l.termCells(c)...))
457 }
458 if passed > 0 {
459 word := "builds"
460 if passed == 1 {
461 word = "build"
462 }
463 activity.note, activity.empty = fmt.Sprintf("%d %s passed", passed, word), true
464 }
465 s.sections = append(s.sections, activity)
466
467 if len(d.Pinned) > 0 {
468 paths := make([]string, len(d.Pinned))
469 for i, p := range d.Pinned {
470 paths[i] = p.Path
471 }
472 s.sections = append(s.sections, section{title: "Pinned", n: len(d.Pinned), rows: []row{rowOf(cMeta(strings.Join(paths, " ")))}})
473 }
474
475 if len(d.Reviews) > 0 {
476 it := d.Reviews[0]
477 s.actions = append(s.actions, action{"Next", []string{"mr", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
478 }
479 if len(d.Assigned) > 0 {
480 it := d.Assigned[0]
481 s.actions = append(s.actions, action{"Next", []string{"issue", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
482 }
483 if firstFailed != nil {
484 s.actions = append(s.actions, action{"Next", []string{"build", "log", firstFailed.Repo, strconv.FormatInt(firstFailed.Number, 10)}})
485 }
486 s.actions = append(s.actions, action{"More", []string{"feed"}})
487 if c.User.IsAdmin {
488 s.actions = append(s.actions, action{"Instance", []string{"admin", "stats"}})
489 }
490 return s
491}
internal/control/dashscreen_test.go added +99
@@ -0,0 +1,99 @@
1package control
2
3import (
4 "slices"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func TestDashboardPlainPinned(t *testing.T) {
13 st, repo, owner := twoMRTestRepo(t)
14 // Both merge requests are created in the same second; the dashboard
15 // orders by updated_at alone, so give them distinct times.
16 if _, err := st.DB.Exec(`UPDATE merge_requests SET updated_at = '2020-01-01T00:00:00Z' WHERE repo_id = ? AND number = 1`, repo.ID); err != nil {
17 t.Fatal(err)
18 }
19 c, out, errOut := mrTestCtx(st, owner)
20 if code := Dispatch(c, []string{"dashboard"}); code != protocol.ExitOK {
21 t.Fatalf("exit %d: %s", code, errOut)
22 }
23 pinPlain(t, "dashboard", out.String())
24}
25
26func dashFixture() DashboardOut {
27 return DashboardOut{
28 Assigned: []DashboardItem{{Repo: "krz/skunky-art", Number: 33, Title: "LibRedirect listing", Author: "cmc"}},
29 MRs: []DashboardItem{{Repo: "krz/gitbay", Number: 554, Title: "list screens", Author: "cmc"}},
30 Builds: []DashboardBuild{
31 {Repo: "krz/omaha-metro-blotter", Number: 83, Job: "daily-pull", Status: "failure", Ref: "main", CreatedAt: "2026-09-30T12:00:00Z"},
32 {Repo: "krz/gitbay", Number: 1784, Job: "test", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"},
33 {Repo: "krz/gitbay", Number: 1783, Job: "build", Status: "success", Ref: "main", CreatedAt: "2026-10-01T10:00:00Z"},
34 {Repo: "krz/gitbay", Number: 1700, Job: "build", Status: "failure", Ref: "main", CreatedAt: "2026-09-01T10:00:00Z"},
35 },
36 Server: &ServerOut{Commit: "135f300"},
37 Queues: &store.Queues{},
38 }
39}
40
41func TestDashboardScreen(t *testing.T) {
42 c := screenCtx(110, false)
43 c.User = store.User{Username: "cmc", IsAdmin: true}
44 s := dashboardScreen(c, dashFixture(), nil)
45 var titles []string
46 for _, sec := range s.sections {
47 if len(sec.rows) > 0 || sec.empty {
48 titles = append(titles, sec.title)
49 }
50 }
51 if want := []string{"Assigned issues", "Open merge requests", "Failed builds", "Recent activity"}; !slices.Equal(titles, want) {
52 t.Errorf("sections = %q, want %q", titles, want)
53 }
54 if n := sectionCounts(s)["Failed builds"]; n != 1 {
55 t.Errorf("failed builds = %d, want 1 (an older failure of a job that has since passed is not one)", n)
56 }
57 out := renderString(c, s)
58 for _, unwanted := range []string{"135f300", "Queues", "webhooks"} {
59 if strings.Contains(out, unwanted) {
60 t.Errorf("admin block %q on the dashboard screen:\n%s", unwanted, out)
61 }
62 }
63 if !strings.Contains(out, "2 builds passed") {
64 t.Errorf("no passing-build summary:\n%s", out)
65 }
66 var cmds []string
67 for _, a := range s.actions {
68 cmds = append(cmds, strings.Join(a.argv, " "))
69 }
70 for _, want := range []string{"issue show krz/skunky-art 33", "build log krz/omaha-metro-blotter 83", "feed", "admin stats"} {
71 if !slices.Contains(cmds, want) {
72 t.Errorf("actions %q lack %q", cmds, want)
73 }
74 }
75 checkActions(t, s)
76}
77
78func TestDashboardScreenProblemsLine(t *testing.T) {
79 c := screenCtx(110, false)
80 c.User = store.User{Username: "cmc", IsAdmin: true}
81 d := dashFixture()
82 d.Queues.Mirrors.Errors = 1
83 out := renderString(c, dashboardScreen(c, d, nil))
84 if !strings.Contains(out, "Problems: ✗ 1 failing in the background\n") {
85 t.Errorf("no problems line:\n%s", out)
86 }
87}
88
89func TestDashboardScreenNonAdmin(t *testing.T) {
90 c := screenCtx(110, false)
91 c.User = store.User{Username: "bob"}
92 d := dashFixture()
93 d.Server, d.Queues = nil, nil
94 for _, a := range dashboardScreen(c, d, nil).actions {
95 if a.argv[0] == "admin" {
96 t.Errorf("non-admin offered %q", a.argv)
97 }
98 }
99}
internal/control/marks_test.go −22
@@ -65,28 +65,6 @@ func TestLabelsMark(t *testing.T) {
65 } 65 }
66} 66}
67 67
68func TestFailingBuilds(t *testing.T) {
69 builds := []DashboardBuild{
70 {Repo: "a/x", Job: "test", Ref: "main", Status: "success"},
71 {Repo: "a/x", Job: "test", Ref: "main", Status: "failure"}, // replaced by the newer success
72 {Repo: "a/y", Job: "pull", Ref: "main", Status: "failure"},
73 }
74 if got := failingBuilds(builds); got != 1 {
75 t.Errorf("failingBuilds = %d, want 1", got)
76 }
77}
78
79func TestNeedsYou(t *testing.T) {
80 term := Term{Cols: 80}
81 if got := term.needsYou(DashboardOut{}); got != "Nothing waits on you." {
82 t.Errorf("empty = %q", got)
83 }
84 d := DashboardOut{Reviews: make([]DashboardItem, 2), Unread: 1}
85 if got := term.needsYou(d); got != "Needs you: 2 reviews requested, 1 unread notification" {
86 t.Errorf("needsYou = %q", got)
87 }
88}
89
90func TestStepState(t *testing.T) { 68func TestStepState(t *testing.T) {
91 cases := []struct { 69 cases := []struct {
92 status string 70 status string
internal/control/testdata/plain/dashboard.txt added +15
@@ -0,0 +1,15 @@
1waiting on your review:
2 none
3assigned to you:
4 none
5open merge requests:
6 alice/app!2 two alice
7 alice/app!1 one alice
8open issues:
9 none
10pinned:
11 none
12recent activity:
13 none
14builds:
15 none