internal/control/dashboard.go
373 lines · 12348 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
96func runDashboard(c *Ctx, args []string) int {
97 if len(args) != 0 {
98 return c.usage()
99 }
100 d := DashboardOut{
101 Reviews: []DashboardItem{}, Assigned: []DashboardItem{}, MRs: []DashboardItem{},
102 Issues: []DashboardItem{}, Pinned: []PinnedOut{}, Activity: []FeedOut{}, Builds: []DashboardBuild{},
103 }
104
105 pinned, err := c.Store.PinnedRepos(c.User.ID)
106 if err != nil {
107 return c.fail(protocol.ExitFailure, "%v", err)
108 }
109 for _, r := range pinned {
110 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
111 if err != nil {
112 return c.fail(protocol.ExitFailure, "%v", err)
113 }
114 if !policy.CanRead(c.User, r, grant) {
115 continue
116 }
117 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
118 d.Pinned = append(d.Pinned, PinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
119 }
120
121 mrs, err := c.Store.DashboardMRs(c.User.ID)
122 if err != nil {
123 return c.fail(protocol.ExitFailure, "%v", err)
124 }
125 for _, m := range mrs {
126 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
127 }
128
129 reviews, err := c.Store.ReviewQueue(c.User.ID)
130 if err != nil {
131 return c.fail(protocol.ExitFailure, "%v", err)
132 }
133 for _, m := range reviews {
134 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
135 }
136
137 assigned, err := c.Store.AssignedIssues(c.User.ID)
138 if err != nil {
139 return c.fail(protocol.ExitFailure, "%v", err)
140 }
141 for _, i := range assigned {
142 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
143 }
144
145 issues, err := c.Store.DashboardIssues(c.User.ID)
146 if err != nil {
147 return c.fail(protocol.ExitFailure, "%v", err)
148 }
149 for _, i := range issues {
150 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
151 }
152
153 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
154 if err != nil {
155 return c.fail(protocol.ExitFailure, "%v", err)
156 }
157 d.Activity = feedOutputs(events)
158
159 builds, err := c.Store.RecentBuilds(c.User.ID, 20)
160 if err != nil {
161 return c.fail(protocol.ExitFailure, "%v", err)
162 }
163 for _, b := range builds {
164 d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
165 }
166 if d.Queries, err = PinnedQueries(c.Store, c.User); err != nil {
167 return c.fail(protocol.ExitFailure, "%v", err)
168 }
169 d.Unread = c.Store.UnreadNotices(c.User.ID)
170 if c.User.IsAdmin {
171 d.Server = &ServerOut{Commit: buildinfo.String()}
172 q, err := c.Store.QueueStatus()
173 if err != nil {
174 return c.fail(protocol.ExitFailure, "%v", err)
175 }
176 d.Queues = &q
177 }
178
179 return c.emit(d, func(w io.Writer) {
180 section := func(title string, header []string, rows [][]cell) {
181 if c.Term.Cols > 0 {
182 fmt.Fprintln(w, c.Term.paint(sgrBold, title))
183 } else {
184 fmt.Fprintln(w, title)
185 }
186 if len(rows) == 0 {
187 fmt.Fprintln(w, " none")
188 return
189 }
190 if c.Term.Cols == 0 {
191 for _, r := range rows {
192 parts := make([]string, len(r))
193 for i, cl := range r {
194 parts[i] = cl.s
195 if cl.kind == kindAge {
196 parts[i] = stamp(cl.s)
197 }
198 }
199 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
200 }
201 return
202 }
203 tb := c.table(w, header...)
204 for _, r := range rows {
205 tb.row(r...)
206 }
207 tb.flush()
208 }
209 itemRows := func(items []DashboardItem, marker string) [][]cell {
210 rows := make([][]cell, len(items))
211 for i, item := range items {
212 rows[i] = []cell{cRef(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number)), cFlex(item.Title), cText(item.Author)}
213 }
214 return rows
215 }
216
217 if d.Unread > 0 {
218 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
219 }
220 itemHeader := []string{"REF", "TITLE", "AUTHOR"}
221 section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!"))
222 section("assigned to you:", itemHeader, itemRows(d.Assigned, "#"))
223 section("open merge requests:", itemHeader, itemRows(d.MRs, "!"))
224 section("open issues:", itemHeader, itemRows(d.Issues, "#"))
225 for _, q := range d.Queries {
226 rows := make([][]cell, 0, len(q.Items))
227 for _, it := range q.Items {
228 rows = append(rows, []cell{cRef(it.Ref()), cFlex(it.Title), cText(it.Author)})
229 }
230 title := fmt.Sprintf("query %s (%d):", q.Name, q.Count)
231 if q.Error != "" {
232 title = fmt.Sprintf("query %s: %s", q.Name, q.Error)
233 }
234 section(title, itemHeader, rows)
235 }
236
237 pinnedRows := make([][]cell, len(d.Pinned))
238 for i, p := range d.Pinned {
239 cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)}
240 if p.Archived {
241 cells = append(cells, cText("[archived]"))
242 }
243 pinnedRows[i] = cells
244 }
245 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows)
246
247 lines := FeedLines(events)
248 activityRows := make([][]cell, len(lines))
249 for i, l := range lines {
250 activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
251 }
252 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
253
254 buildRows := make([][]cell, len(d.Builds))
255 for i, b := range d.Builds {
256 buildRows[i] = []cell{cRef(b.Repo), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)}
257 }
258 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
259
260 if d.Server != nil {
261 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit)
262 }
263 if q := d.Queues; q != nil {
264 fmt.Fprintln(w, "queues:")
265
266 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
267 twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR")
268 for _, it := range q.Webhooks.Items {
269 twh.row(cRef(" "+it.Repo), cText(it.URL), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
270 }
271 twh.flush()
272
273 fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
274 tma := c.table(w, "RECIPIENT", "SUBJECT", "ATTEMPTS", "ERROR")
275 for _, it := range q.Mail.Items {
276 tma.row(cRef(" "+it.Recipient), cText(it.Subject), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
277 }
278 tma.flush()
279
280 // The device id, not the token: a token is never echoed.
281 fmt.Fprintf(w, " push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed)
282 tpu := c.table(w, "DEVICE", "TITLE", "ATTEMPTS", "ERROR")
283 for _, it := range q.Push.Items {
284 tpu.row(cRef(fmt.Sprintf(" device %d", it.DeviceID)), cText(it.Title), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
285 }
286 tpu.flush()
287
288 fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
289 tmi := c.table(w, "REPO", "DIRECTION", "URL", "ERROR")
290 for _, it := range q.Mirrors.Items {
291 tmi.row(cRef(" "+it.Repo), cText(it.Direction), cText(it.URL), cText(it.LastError))
292 }
293 tmi.flush()
294
295 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
296 tbq := c.table(w, "REPO", "#", "JOB", "STATUS")
297 for _, it := range q.Builds.Items {
298 since := it.StartedAt
299 if it.Status == "pending" {
300 since = it.CreatedAt
301 }
302 if c.Term.Cols == 0 {
303 since = stamp(since)
304 } else {
305 since = relAge(since, termNow())
306 }
307 tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since)))
308 }
309 tbq.flush()
310
311 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
312 tde := c.table(w, "REPO", "ERROR")
313 for _, it := range q.Deps.Items {
314 tde.row(cRef(" "+it.Repo), cText(it.LastError))
315 }
316 tde.flush()
317 }
318 })
319}
320
321// feedDefaultLimit caps a bare `feed` call; pagination reaches further
322// back.
323const feedDefaultLimit = 50
324
325type FeedOut struct {
326 ID int64 `json:"id"`
327 Repo string `json:"repo"`
328 Actor string `json:"actor,omitempty"`
329 Kind string `json:"kind"`
330 Data json.RawMessage `json:"data,omitempty"`
331 CreatedAt string `json:"created_at"`
332}
333
334func feedOutputs(events []store.FeedEvent) []FeedOut {
335 ds := make([]FeedOut, 0, len(events))
336 for _, e := range events {
337 d := FeedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
338 if json.Valid([]byte(e.Data)) {
339 d.Data = json.RawMessage(e.Data)
340 }
341 ds = append(ds, d)
342 }
343 return ds
344}
345
346func runFeed(c *Ctx, args []string) int {
347 rest, p, code := parsePageFlags(c, args, "feed", true)
348 if code >= 0 {
349 return code
350 }
351 if len(rest) != 0 {
352 return c.usage()
353 }
354 if p.limit == 0 {
355 p.limit = feedDefaultLimit
356 }
357 events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt())
358 if err != nil {
359 return c.fail(protocol.ExitFailure, "%v", err)
360 }
361 events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
362 return strconv.FormatInt(e.ID, 10)
363 })
364 ds := feedOutputs(events)
365 lines := FeedLines(events)
366 return c.emitPage(p, ds, next, func(w io.Writer) {
367 tb := c.table(w, "WHEN", "EVENT")
368 for _, l := range lines {
369 tb.row(cAge(l.When), cFlex(l.Sentence()))
370 }
371 tb.flush()
372 })
373}