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