internal/control/dashboard.go
459 lines · 14830 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 = 8
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
140func runDashboard(c *Ctx, args []string) int {
141 if len(args) != 0 {
142 return c.usage()
143 }
144 d := DashboardOut{
145 Reviews: []DashboardItem{}, Assigned: []DashboardItem{}, MRs: []DashboardItem{},
146 Issues: []DashboardItem{}, Pinned: []PinnedOut{}, Activity: []FeedOut{}, Builds: []DashboardBuild{},
147 }
148
149 pinned, err := c.Store.PinnedRepos(c.User.ID)
150 if err != nil {
151 return c.fail(protocol.ExitFailure, "%v", err)
152 }
153 for _, r := range pinned {
154 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
155 if err != nil {
156 return c.fail(protocol.ExitFailure, "%v", err)
157 }
158 if !policy.CanRead(c.User, r, grant) {
159 continue
160 }
161 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
162 d.Pinned = append(d.Pinned, PinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
163 }
164
165 mrs, err := c.Store.DashboardMRs(c.User.ID)
166 if err != nil {
167 return c.fail(protocol.ExitFailure, "%v", err)
168 }
169 for _, m := range mrs {
170 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
171 }
172
173 reviews, err := c.Store.ReviewQueue(c.User.ID)
174 if err != nil {
175 return c.fail(protocol.ExitFailure, "%v", err)
176 }
177 for _, m := range reviews {
178 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
179 }
180
181 assigned, err := c.Store.AssignedIssues(c.User.ID)
182 if err != nil {
183 return c.fail(protocol.ExitFailure, "%v", err)
184 }
185 for _, i := range assigned {
186 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
187 }
188
189 issues, err := c.Store.DashboardIssues(c.User.ID)
190 if err != nil {
191 return c.fail(protocol.ExitFailure, "%v", err)
192 }
193 for _, i := range issues {
194 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
195 }
196
197 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
198 if err != nil {
199 return c.fail(protocol.ExitFailure, "%v", err)
200 }
201 d.Activity = feedOutputs(events)
202
203 builds, err := c.Store.RecentBuilds(c.User.ID, 20)
204 if err != nil {
205 return c.fail(protocol.ExitFailure, "%v", err)
206 }
207 for _, b := range builds {
208 d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
209 }
210 if d.Queries, err = PinnedQueries(c.Store, c.User); err != nil {
211 return c.fail(protocol.ExitFailure, "%v", err)
212 }
213 d.Unread = c.Store.UnreadNotices(c.User.ID)
214 if c.User.IsAdmin {
215 d.Server = &ServerOut{Commit: buildinfo.String()}
216 q, err := c.Store.QueueStatus()
217 if err != nil {
218 return c.fail(protocol.ExitFailure, "%v", err)
219 }
220 d.Queues = &q
221 }
222
223 return c.emit(d, func(w io.Writer) {
224 // At a terminal sections are separated by a blank line, and an
225 // empty one is left out.
226 wrote := false
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)
243 if len(rows) == 0 {
244 fmt.Fprintln(w, " none")
245 return
246 }
247 if c.Term.Cols == 0 {
248 for _, r := range rows {
249 parts := make([]string, len(r))
250 for i, cl := range r {
251 parts[i] = cl.s
252 if cl.kind == kindAge {
253 parts[i] = stamp(cl.s)
254 }
255 }
256 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
257 }
258 return
259 }
260 tb := c.table(w, header...)
261 for _, r := range rows {
262 tb.row(r...)
263 }
264 tb.flush()
265 }
266 itemRows := func(items []DashboardItem, marker string) [][]cell {
267 rows := make([][]cell, len(items))
268 for i, item := range items {
269 page := "issues"
270 if marker == "!" {
271 page = "mrs"
272 }
273 ref := cLink(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number), c.siteURL(item.Repo, page, strconv.FormatInt(item.Number, 10)))
274 rows[i] = []cell{ref, cFlex(item.Title), cText(item.Author)}
275 }
276 return rows
277 }
278
279 if c.Term.Cols > 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)
284 }
285 itemHeader := []string{"REF", "TITLE", "AUTHOR"}
286 section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!"))
287 section("assigned to you:", itemHeader, itemRows(d.Assigned, "#"))
288 section("open merge requests:", itemHeader, itemRows(d.MRs, "!"))
289 section("open issues:", itemHeader, itemRows(d.Issues, "#"))
290 for _, q := range d.Queries {
291 rows := make([][]cell, 0, len(q.Items))
292 for _, it := range q.Items {
293 rows = append(rows, []cell{cRef(it.Ref()), cFlex(it.Title), cText(it.Author)})
294 }
295 title := fmt.Sprintf("query %s (%d):", q.Name, q.Count)
296 if q.Error != "" {
297 title = fmt.Sprintf("query %s: %s", q.Name, q.Error)
298 }
299 section(title, itemHeader, rows)
300 }
301
302 pinnedRows := make([][]cell, len(d.Pinned))
303 for i, p := range d.Pinned {
304 cells := []cell{cLink(p.Path, c.siteURL(p.Path)), cState(p.Visibility), cFlex(p.Description)}
305 if p.Archived {
306 cells = c.note(cells, 1, "[archived]", "archived")
307 }
308 pinnedRows[i] = cells
309 }
310 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows)
311
312 lines := FeedLines(events)
313 if c.Term.Cols > 0 {
314 // The feed has its own command; the dashboard shows the start.
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 }
330
331 buildRows := make([][]cell, len(d.Builds))
332 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)}
334 }
335 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
336
337 if d.Server != nil {
338 heading("server:")
339 fmt.Fprintf(w, " build %s\n", d.Server.Commit)
340 }
341 if q := d.Queues; q != nil {
342 heading("queues:")
343
344 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
345 twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR")
346 for _, it := range q.Webhooks.Items {
347 twh.row(cRef(" "+it.Repo), cText(it.URL), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
348 }
349 twh.flush()
350
351 fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
352 tma := c.table(w, "RECIPIENT", "SUBJECT", "ATTEMPTS", "ERROR")
353 for _, it := range q.Mail.Items {
354 tma.row(cRef(" "+it.Recipient), cText(it.Subject), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
355 }
356 tma.flush()
357
358 // The device id, not the token: a token is never echoed.
359 fmt.Fprintf(w, " push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed)
360 tpu := c.table(w, "DEVICE", "TITLE", "ATTEMPTS", "ERROR")
361 for _, it := range q.Push.Items {
362 tpu.row(cRef(fmt.Sprintf(" device %d", it.DeviceID)), cText(it.Title), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
363 }
364 tpu.flush()
365
366 fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
367 tmi := c.table(w, "REPO", "DIRECTION", "URL", "ERROR")
368 for _, it := range q.Mirrors.Items {
369 tmi.row(cRef(" "+it.Repo), cText(it.Direction), cText(it.URL), cText(it.LastError))
370 }
371 tmi.flush()
372
373 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
374 tbq := c.table(w, "REPO", "#", "JOB", "STATUS")
375 for _, it := range q.Builds.Items {
376 since := it.StartedAt
377 if it.Status == "pending" {
378 since = it.CreatedAt
379 }
380 if c.Term.Cols == 0 {
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)))
386 }
387 tbq.flush()
388
389 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
390 tde := c.table(w, "REPO", "ERROR")
391 for _, it := range q.Deps.Items {
392 tde.row(cRef(" "+it.Repo), cText(it.LastError))
393 }
394 tde.flush()
395 }
396 })
397}
398
399// feedDefaultLimit caps a bare `feed` call; pagination reaches further
400// back.
401const feedDefaultLimit = 50
402
403type FeedOut struct {
404 ID int64 `json:"id"`
405 Repo string `json:"repo"`
406 Actor string `json:"actor,omitempty"`
407 Kind string `json:"kind"`
408 Data json.RawMessage `json:"data,omitempty"`
409 CreatedAt string `json:"created_at"`
410}
411
412func feedOutputs(events []store.FeedEvent) []FeedOut {
413 ds := make([]FeedOut, 0, len(events))
414 for _, e := range events {
415 d := FeedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
416 if json.Valid([]byte(e.Data)) {
417 d.Data = json.RawMessage(e.Data)
418 }
419 ds = append(ds, d)
420 }
421 return ds
422}
423
424func runFeed(c *Ctx, args []string) int {
425 rest, p, code := parsePageFlags(c, args, "feed", true)
426 if code >= 0 {
427 return code
428 }
429 if len(rest) != 0 {
430 return c.usage()
431 }
432 if p.limit == 0 {
433 p.limit = feedDefaultLimit
434 }
435 events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt())
436 if err != nil {
437 return c.fail(protocol.ExitFailure, "%v", err)
438 }
439 events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
440 return strconv.FormatInt(e.ID, 10)
441 })
442 ds := feedOutputs(events)
443 lines := FeedLines(events)
444 return c.emitPage(p, ds, next, func(w io.Writer) {
445 if c.Term.Cols > 0 {
446 tb := c.table(w, feedHeader...)
447 for _, l := range lines {
448 tb.row(l.termCells(c)...)
449 }
450 tb.flush()
451 return
452 }
453 tb := c.table(w, "WHEN", "EVENT")
454 for _, l := range lines {
455 tb.row(cAge(l.When), cFlex(l.Sentence()))
456 }
457 tb.flush()
458 })
459}