internal/control/dashboard.go
489 lines · 16636 bytes
1package control
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "strconv"
8 "strings"
9
10 "gitbay.org/gitbay/internal/buildinfo"
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17func init() {
18 register(Command{Path: []string{"dashboard"},
19 Summary: "one read for the account dashboard: review queue, assigned and open work, pins, activity, builds",
20 Usage: "dashboard",
21 Examples: []string{"dashboard"},
22 ReadOnly: true, Run: runDashboard})
23 register(Command{Path: []string{"feed"},
24 Summary: "activity on repositories you can reach",
25 Usage: "feed [--limit <n>] [--cursor <c>]",
26 Flags: []Flag{
27 {"--limit", "<n>", "rows per page", ""},
28 {"--cursor", "<c>", "continue from the previous page", ""},
29 },
30 Examples: []string{"feed --limit 20"},
31 ReadOnly: true, Run: runFeed})
32}
33
34// DashboardItem is one open issue or MR row, with its repo resolved so a
35// client renders the aggregate without further reads.
36type DashboardItem struct {
37 Repo string `json:"repo"`
38 Number int64 `json:"number"`
39 Title string `json:"title"`
40 Author string `json:"author"`
41 State string `json:"state"`
42 UpdatedAt string `json:"updated_at"`
43 // Queued marks a merge request with a queued merge.
44 Queued bool `json:"queued,omitempty"`
45}
46
47// PinnedOut is one pinned repository on the dashboard.
48type PinnedOut struct {
49 Path string `json:"path"`
50 Visibility string `json:"visibility"`
51 Description string `json:"description,omitempty"`
52 Archived bool `json:"archived,omitempty"`
53}
54
55// DashboardBuild is a build with its repository resolved, which is what
56// separates it from BuildOut: the dashboard spans repositories.
57type DashboardBuild struct {
58 Repo string `json:"repo"`
59 Number int64 `json:"number"`
60 Job string `json:"job"`
61 Status string `json:"status"`
62 SHA string `json:"sha"`
63 Ref string `json:"ref"`
64 CreatedAt string `json:"created_at"`
65 FinishedAt string `json:"finished_at,omitempty"`
66}
67
68// ServerOut is admin-only. The exact build a host is running narrows down
69// which known issues apply to it, so it is not everyone's to read; the
70// person who needs it is the operator.
71type ServerOut struct {
72 Commit string `json:"commit"`
73}
74
75// DashboardOut is what dashboard emits: the whole account aggregate in
76// one read.
77type DashboardOut struct {
78 Reviews []DashboardItem `json:"review_queue"`
79 Assigned []DashboardItem `json:"assigned_issues"`
80 MRs []DashboardItem `json:"open_mrs"`
81 Issues []DashboardItem `json:"open_issues"`
82 Pinned []PinnedOut `json:"pinned"`
83 Activity []FeedOut `json:"recent_activity"`
84 Builds []DashboardBuild `json:"builds"`
85 // Queries is each pinned saved query with its first rows.
86 Queries []DashboardQuery `json:"queries"`
87 // Unread is the notification inbox badge, so a client showing one
88 // does not need a second read to fill it.
89 Unread int `json:"unread"`
90 Server *ServerOut `json:"server,omitempty"`
91 // Queues is admin-only: every background worker's backlog and
92 // failures, the operator's view of what is stuck.
93 Queues *store.Queues `json:"queues,omitempty"`
94}
95
96// dashboardActivity is how many feed lines the dashboard shows at a
97// terminal.
98const dashboardActivity = 5
99
100func runDashboard(c *Ctx, args []string) int {
101 if len(args) != 0 {
102 return c.usage()
103 }
104 d := DashboardOut{
105 Reviews: []DashboardItem{}, Assigned: []DashboardItem{}, MRs: []DashboardItem{},
106 Issues: []DashboardItem{}, Pinned: []PinnedOut{}, Activity: []FeedOut{}, Builds: []DashboardBuild{},
107 }
108
109 pinned, err := c.Store.PinnedRepos(c.User.ID)
110 if err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 for _, r := range pinned {
114 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
115 if err != nil {
116 return c.fail(protocol.ExitFailure, "%v", err)
117 }
118 if !policy.CanRead(c.User, r, grant) {
119 continue
120 }
121 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
122 d.Pinned = append(d.Pinned, PinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
123 }
124
125 mrs, err := c.Store.DashboardMRs(c.User.ID)
126 if err != nil {
127 return c.fail(protocol.ExitFailure, "%v", err)
128 }
129 for _, m := range mrs {
130 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
131 }
132
133 reviews, err := c.Store.ReviewQueue(c.User.ID)
134 if err != nil {
135 return c.fail(protocol.ExitFailure, "%v", err)
136 }
137 for _, m := range reviews {
138 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
139 }
140
141 assigned, err := c.Store.AssignedIssues(c.User.ID)
142 if err != nil {
143 return c.fail(protocol.ExitFailure, "%v", err)
144 }
145 for _, i := range assigned {
146 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
147 }
148
149 issues, err := c.Store.DashboardIssues(c.User.ID)
150 if err != nil {
151 return c.fail(protocol.ExitFailure, "%v", err)
152 }
153 for _, i := range issues {
154 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
155 }
156
157 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
158 if err != nil {
159 return c.fail(protocol.ExitFailure, "%v", err)
160 }
161 d.Activity = feedOutputs(events)
162
163 builds, err := c.Store.RecentBuilds(c.User.ID, 20)
164 if err != nil {
165 return c.fail(protocol.ExitFailure, "%v", err)
166 }
167 for _, b := range builds {
168 d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
169 }
170 if d.Queries, err = PinnedQueries(c.Store, c.User); err != nil {
171 return c.fail(protocol.ExitFailure, "%v", err)
172 }
173 d.Unread = c.Store.UnreadNotices(c.User.ID)
174 if c.User.IsAdmin {
175 d.Server = &ServerOut{Commit: buildinfo.String()}
176 q, err := c.Store.QueueStatus()
177 if err != nil {
178 return c.fail(protocol.ExitFailure, "%v", err)
179 }
180 d.Queues = &q
181 }
182
183 lines := FeedLines(events)
184 return c.emitView(d, func(w io.Writer) {
185 heading := func(title string) { fmt.Fprintln(w, title) }
186 section := func(title string, rows [][]cell) {
187 heading(title)
188 if len(rows) == 0 {
189 fmt.Fprintln(w, " none")
190 return
191 }
192 for _, r := range rows {
193 parts := make([]string, len(r))
194 for i, cl := range r {
195 parts[i] = cl.s
196 if cl.kind == kindAge {
197 parts[i] = stamp(cl.s)
198 }
199 }
200 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
201 }
202 }
203 itemRows := func(items []DashboardItem, marker string) [][]cell {
204 rows := make([][]cell, len(items))
205 for i, item := range items {
206 page := "issues"
207 if marker == "!" {
208 page = "mrs"
209 }
210 ref := cLink(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number), c.siteURL(item.Repo, page, strconv.FormatInt(item.Number, 10)))
211 rows[i] = []cell{ref, cFlex(item.Title), cText(item.Author)}
212 }
213 return rows
214 }
215
216 if d.Unread > 0 {
217 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
218 }
219 section("waiting on your review:", itemRows(d.Reviews, "!"))
220 section("assigned to you:", itemRows(d.Assigned, "#"))
221 section("open merge requests:", itemRows(d.MRs, "!"))
222 section("open issues:", itemRows(d.Issues, "#"))
223 for _, q := range d.Queries {
224 rows := make([][]cell, 0, len(q.Items))
225 for _, it := range q.Items {
226 rows = append(rows, []cell{cRef(it.Ref()), cFlex(it.Title), cText(it.Author)})
227 }
228 title := fmt.Sprintf("query %s (%d):", q.Name, q.Count)
229 if q.Error != "" {
230 title = fmt.Sprintf("query %s: %s", q.Name, q.Error)
231 }
232 section(title, rows)
233 }
234
235 pinnedRows := make([][]cell, len(d.Pinned))
236 for i, p := range d.Pinned {
237 cells := []cell{cLink(p.Path, c.siteURL(p.Path)), cState(p.Visibility), cFlex(p.Description)}
238 if p.Archived {
239 cells = c.note(cells, 1, "[archived]", "archived")
240 }
241 pinnedRows[i] = cells
242 }
243 section("pinned:", pinnedRows)
244
245 activityRows := make([][]cell, len(lines))
246 for i, l := range lines {
247 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
248 }
249 section("recent activity:", activityRows)
250
251 buildRows := make([][]cell, len(d.Builds))
252 for i, b := range d.Builds {
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)}
254 }
255 section("builds:", buildRows)
256
257 if d.Server != nil {
258 heading("server:")
259 fmt.Fprintf(w, " build %s\n", d.Server.Commit)
260 }
261 if q := d.Queues; q != nil {
262 heading("queues:")
263
264 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
265 twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR")
266 for _, it := range q.Webhooks.Items {
267 twh.row(cRef(" "+it.Repo), cText(it.URL), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
268 }
269 twh.flush()
270
271 fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
272 tma := c.table(w, "RECIPIENT", "SUBJECT", "ATTEMPTS", "ERROR")
273 for _, it := range q.Mail.Items {
274 tma.row(cRef(" "+it.Recipient), cText(it.Subject), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
275 }
276 tma.flush()
277
278 // The device id, not the token: a token is never echoed.
279 fmt.Fprintf(w, " push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed)
280 tpu := c.table(w, "DEVICE", "TITLE", "ATTEMPTS", "ERROR")
281 for _, it := range q.Push.Items {
282 tpu.row(cRef(fmt.Sprintf(" device %d", it.DeviceID)), cText(it.Title), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
283 }
284 tpu.flush()
285
286 fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
287 tmi := c.table(w, "REPO", "DIRECTION", "URL", "ERROR")
288 for _, it := range q.Mirrors.Items {
289 tmi.row(cRef(" "+it.Repo), cText(it.Direction), cText(it.URL), cText(it.LastError))
290 }
291 tmi.flush()
292
293 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
294 tbq := c.table(w, "REPO", "#", "JOB", "STATUS")
295 for _, it := range q.Builds.Items {
296 since := it.StartedAt
297 if it.Status == "pending" {
298 since = it.CreatedAt
299 }
300 since = stamp(since)
301 tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since)))
302 }
303 tbq.flush()
304
305 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
306 tde := c.table(w, "REPO", "ERROR")
307 for _, it := range q.Deps.Items {
308 tde.row(cRef(" "+it.Repo), cText(it.LastError))
309 }
310 tde.flush()
311 }
312 }, func() screen { return dashboardScreen(c, d, lines) })
313}
314
315// feedDefaultLimit caps a bare `feed` call; pagination reaches further
316// back.
317const feedDefaultLimit = 50
318
319type FeedOut struct {
320 ID int64 `json:"id"`
321 Repo string `json:"repo"`
322 Actor string `json:"actor,omitempty"`
323 Kind string `json:"kind"`
324 Data json.RawMessage `json:"data,omitempty"`
325 CreatedAt string `json:"created_at"`
326}
327
328func feedOutputs(events []store.FeedEvent) []FeedOut {
329 ds := make([]FeedOut, 0, len(events))
330 for _, e := range events {
331 d := FeedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
332 if json.Valid([]byte(e.Data)) {
333 d.Data = json.RawMessage(e.Data)
334 }
335 ds = append(ds, d)
336 }
337 return ds
338}
339
340func runFeed(c *Ctx, args []string) int {
341 rest, p, code := parsePageFlags(c, args, "feed", true)
342 if code >= 0 {
343 return code
344 }
345 if len(rest) != 0 {
346 return c.usage()
347 }
348 if p.limit == 0 {
349 p.limit = feedDefaultLimit
350 }
351 events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt())
352 if err != nil {
353 return c.fail(protocol.ExitFailure, "%v", err)
354 }
355 events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
356 return strconv.FormatInt(e.ID, 10)
357 })
358 ds := feedOutputs(events)
359 lines := FeedLines(events)
360 return c.emitPageView(p, ds, next, func(w io.Writer) {
361 tb := c.table(w, "WHEN", "EVENT")
362 for _, l := range lines {
363 tb.row(cAge(l.When), cFlex(l.Sentence()))
364 }
365 tb.flush()
366 }, func() screen {
367 rows := make([]row, len(lines))
368 for i, l := range lines {
369 rows[i] = rowOf(l.termCells(c)...)
370 }
371 return listScreen("Activity", rows)
372 })
373}
374
375// dashboardScreen is dashboard at a terminal: what waits on the viewer
376// first, then open merge requests, failed builds, a few lines of
377// activity and the pinned repositories. The operator's queues are
378// admin stats'; a background failure shows as one header line.
379func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen {
380 var s screen
381 s.fields = append(s.fields, field{"User", []cell{cText(c.User.Username)}})
382 if host := c.Cfg.SiteHost(); host != "" {
383 s.fields = append(s.fields, field{"Instance", []cell{cText(host)}})
384 }
385 if q := d.Queues; q != nil {
386 if bad := q.Webhooks.Failed + q.Mail.Failed + q.Push.Failed + q.Mirrors.Errors + q.Deps.Errors; bad > 0 {
387 s.fields = append(s.fields, field{"Problems", []cell{cGlyph("failed"), cText(fmt.Sprintf("%d failing in the background", bad))}})
388 }
389 }
390 if d.Unread > 0 {
391 s.fields = append(s.fields, field{"Inbox", []cell{cYou(), cText(fmt.Sprintf("%d unread", d.Unread))}})
392 }
393
394 item := func(it DashboardItem, marker, page string, you bool) row {
395 lead := cell{kind: kindGlyph}
396 if you {
397 lead = cYou()
398 }
399 ref := cLink(fmt.Sprintf("%s%s%d", it.Repo, marker, it.Number), c.siteURL(it.Repo, page, strconv.FormatInt(it.Number, 10)))
400 return rowOf(ref, lead, cFlex(it.Title), cMeta(it.Author))
401 }
402 items := func(title string, its []DashboardItem, marker, page string, you bool) section {
403 sec := section{title: title, n: len(its)}
404 for _, it := range its {
405 sec.rows = append(sec.rows, item(it, marker, page, you))
406 }
407 return sec
408 }
409 s.sections = append(s.sections,
410 items("Review requested", d.Reviews, "!", "mrs", true),
411 items("Assigned issues", d.Assigned, "#", "issues", true),
412 items("Open merge requests", d.MRs, "!", "mrs", false),
413 )
414 for _, q := range d.Queries {
415 sec := section{title: q.Name, n: q.Count, more: []string{"query", "run", q.Name}}
416 if q.Error != "" {
417 sec.note, sec.empty = q.Error, true
418 }
419 for _, it := range q.Items {
420 sec.rows = append(sec.rows, rowOf(cRef(it.Ref()), cFlex(it.Title), cMeta(it.Author)))
421 }
422 s.sections = append(s.sections, sec)
423 }
424
425 // A job's latest build is the one that counts: a failure a later
426 // build of the same job and ref has replaced is not shown.
427 failed := section{title: "Failed builds"}
428 passed := 0
429 seen := map[string]bool{}
430 var firstFailed *DashboardBuild
431 for i, b := range d.Builds {
432 key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
433 if seen[key] {
434 continue
435 }
436 seen[key] = true
437 switch b.Status {
438 case "success":
439 passed++
440 case "failure", "error":
441 n := strconv.FormatInt(b.Number, 10)
442 failed.n++
443 failed.rows = append(failed.rows, rowOf(cLink(n, c.siteURL(b.Repo, "builds", n)), cGlyph(b.Status),
444 cFlex(b.Job+" "+b.Ref), cMeta(b.Repo, relAge(b.CreatedAt, termNow()))))
445 if firstFailed == nil {
446 firstFailed = &d.Builds[i]
447 }
448 }
449 }
450 s.sections = append(s.sections, failed)
451
452 activity := section{title: "Recent activity", n: len(lines), more: []string{"feed"}}
453 for _, l := range lines[:min(len(lines), dashboardActivity)] {
454 activity.rows = append(activity.rows, rowOf(l.termCells(c)...))
455 }
456 if passed > 0 {
457 word := "builds"
458 if passed == 1 {
459 word = "build"
460 }
461 activity.note, activity.empty = fmt.Sprintf("%d %s passed", passed, word), true
462 }
463 s.sections = append(s.sections, activity)
464
465 if len(d.Pinned) > 0 {
466 paths := make([]string, len(d.Pinned))
467 for i, p := range d.Pinned {
468 paths[i] = p.Path
469 }
470 s.sections = append(s.sections, section{title: "Pinned", n: len(d.Pinned), rows: []row{rowOf(cMeta(strings.Join(paths, " ")))}})
471 }
472
473 if len(d.Reviews) > 0 {
474 it := d.Reviews[0]
475 s.actions = append(s.actions, action{"Next", []string{"mr", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
476 }
477 if len(d.Assigned) > 0 {
478 it := d.Assigned[0]
479 s.actions = append(s.actions, action{"Next", []string{"issue", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
480 }
481 if firstFailed != nil {
482 s.actions = append(s.actions, action{"Next", []string{"build", "log", firstFailed.Repo, strconv.FormatInt(firstFailed.Number, 10)}})
483 }
484 s.actions = append(s.actions, action{"More", []string{"feed"}})
485 if c.User.IsAdmin {
486 s.actions = append(s.actions, action{"Instance", []string{"admin", "stats"}})
487 }
488 return s
489}