internal/control/dashboard.go
268 lines · 9059 bytes
1package control
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "strconv"
8
9 "gitbay.org/gitbay/internal/buildinfo"
10 "gitbay.org/gitbay/internal/gitutil"
11 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16func init() {
17 register(Command{Path: []string{"dashboard"},
18 Summary: "one read for the account dashboard: review queue, assigned and open work, pins, activity, builds",
19 Usage: "dashboard",
20 ReadOnly: true, Run: runDashboard})
21 register(Command{Path: []string{"feed"},
22 Summary: "activity on repositories you can reach",
23 Usage: "feed [--limit <n>] [--cursor <c>]",
24 ReadOnly: true, Run: runFeed})
25}
26
27// dashboardItem is one open issue or MR row, with its repo resolved so a
28// client renders the aggregate without further reads.
29type dashboardItem struct {
30 Repo string `json:"repo"`
31 Number int64 `json:"number"`
32 Title string `json:"title"`
33 Author string `json:"author"`
34 State string `json:"state"`
35 UpdatedAt string `json:"updated_at"`
36}
37
38func runDashboard(c *Ctx, args []string) int {
39 if len(args) != 0 {
40 return c.fail(protocol.ExitUsage, "usage: dashboard")
41 }
42 type pinnedOut struct {
43 Path string `json:"path"`
44 Visibility string `json:"visibility"`
45 Description string `json:"description,omitempty"`
46 Archived bool `json:"archived,omitempty"`
47 }
48 type buildOut struct {
49 Repo string `json:"repo"`
50 Number int64 `json:"number"`
51 Job string `json:"job"`
52 Status string `json:"status"`
53 SHA string `json:"sha"`
54 Ref string `json:"ref"`
55 CreatedAt string `json:"created_at"`
56 FinishedAt string `json:"finished_at,omitempty"`
57 }
58 // serverOut is admin-only. The exact build a host is running narrows down
59 // which known issues apply to it, so it is not everyone's to read; the
60 // person who needs it is the operator.
61 type serverOut struct {
62 Commit string `json:"commit"`
63 }
64 type out struct {
65 Reviews []dashboardItem `json:"review_queue"`
66 Assigned []dashboardItem `json:"assigned_issues"`
67 MRs []dashboardItem `json:"open_mrs"`
68 Issues []dashboardItem `json:"open_issues"`
69 Pinned []pinnedOut `json:"pinned"`
70 Activity []feedOut `json:"recent_activity"`
71 Builds []buildOut `json:"builds"`
72 // Unread is the notification inbox badge, so a client showing one
73 // does not need a second read to fill it.
74 Unread int `json:"unread"`
75 Server *serverOut `json:"server,omitempty"`
76 // Queues is admin-only: every background worker's backlog and
77 // failures, the operator's view of what is stuck.
78 Queues *store.Queues `json:"queues,omitempty"`
79 }
80 d := out{
81 Reviews: []dashboardItem{}, Assigned: []dashboardItem{}, MRs: []dashboardItem{},
82 Issues: []dashboardItem{}, Pinned: []pinnedOut{}, Activity: []feedOut{}, Builds: []buildOut{},
83 }
84
85 pinned, err := c.Store.PinnedRepos(c.User.ID)
86 if err != nil {
87 return c.fail(protocol.ExitFailure, "%v", err)
88 }
89 for _, r := range pinned {
90 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
91 if err != nil {
92 return c.fail(protocol.ExitFailure, "%v", err)
93 }
94 if !policy.CanRead(c.User, r, grant) {
95 continue
96 }
97 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
98 d.Pinned = append(d.Pinned, pinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
99 }
100
101 mrs, err := c.Store.DashboardMRs(c.User.ID)
102 if err != nil {
103 return c.fail(protocol.ExitFailure, "%v", err)
104 }
105 for _, m := range mrs {
106 d.MRs = append(d.MRs, dashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
107 }
108
109 reviews, err := c.Store.ReviewQueue(c.User.ID)
110 if err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 for _, m := range reviews {
114 d.Reviews = append(d.Reviews, dashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
115 }
116
117 assigned, err := c.Store.AssignedIssues(c.User.ID)
118 if err != nil {
119 return c.fail(protocol.ExitFailure, "%v", err)
120 }
121 for _, i := range assigned {
122 d.Assigned = append(d.Assigned, dashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
123 }
124
125 issues, err := c.Store.DashboardIssues(c.User.ID)
126 if err != nil {
127 return c.fail(protocol.ExitFailure, "%v", err)
128 }
129 for _, i := range issues {
130 d.Issues = append(d.Issues, dashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
131 }
132
133 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
134 if err != nil {
135 return c.fail(protocol.ExitFailure, "%v", err)
136 }
137 d.Activity = feedOutputs(events)
138
139 builds, err := c.Store.RecentBuilds(c.User.ID, 20)
140 if err != nil {
141 return c.fail(protocol.ExitFailure, "%v", err)
142 }
143 for _, b := range builds {
144 d.Builds = append(d.Builds, buildOut{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
145 }
146 d.Unread = c.Store.UnreadNotices(c.User.ID)
147 if c.User.IsAdmin {
148 d.Server = &serverOut{Commit: buildinfo.String()}
149 q, err := c.Store.QueueStatus()
150 if err != nil {
151 return c.fail(protocol.ExitFailure, "%v", err)
152 }
153 d.Queues = &q
154 }
155
156 return c.emit(d, func(w io.Writer) {
157 if d.Unread > 0 {
158 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
159 }
160 fmt.Fprintln(w, "waiting on your review:")
161 printDashboardItems(w, d.Reviews, "!")
162 fmt.Fprintln(w, "assigned to you:")
163 printDashboardItems(w, d.Assigned, "#")
164 fmt.Fprintln(w, "open merge requests:")
165 printDashboardItems(w, d.MRs, "!")
166 fmt.Fprintln(w, "open issues:")
167 printDashboardItems(w, d.Issues, "#")
168 fmt.Fprintln(w, "pinned:")
169 for _, p := range d.Pinned {
170 mark := ""
171 if p.Archived {
172 mark = "\t[archived]"
173 }
174 fmt.Fprintf(w, " %s\t%s\t%s%s\n", p.Path, p.Visibility, p.Description, mark)
175 }
176 fmt.Fprintln(w, "recent activity:")
177 for _, e := range d.Activity {
178 fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", e.CreatedAt, e.Actor, e.Kind, e.Repo, string(e.Data))
179 }
180 fmt.Fprintln(w, "builds:")
181 for _, b := range d.Builds {
182 fmt.Fprintf(w, " %s\t%d\t%s\t%s\t%.10s\t%s\n", b.Repo, b.Number, b.Job, b.Status, b.SHA, b.Ref)
183 }
184 if d.Server != nil {
185 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit)
186 }
187 if q := d.Queues; q != nil {
188 fmt.Fprintln(w, "queues:")
189 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
190 for _, it := range q.Webhooks.Items {
191 fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Repo, it.URL, it.Attempts, it.LastError)
192 }
193 fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
194 for _, it := range q.Mail.Items {
195 fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Recipient, it.Subject, it.Attempts, it.LastError)
196 }
197 fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
198 for _, it := range q.Mirrors.Items {
199 fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", it.Repo, it.Direction, it.URL, it.LastError)
200 }
201 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
202 for _, it := range q.Builds.Items {
203 fmt.Fprintf(w, " %s\t%d\t%s\tsince %s\n", it.Repo, it.Number, it.Job, it.StartedAt)
204 }
205 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
206 for _, it := range q.Deps.Items {
207 fmt.Fprintf(w, " %s\t%s\n", it.Repo, it.LastError)
208 }
209 }
210 })
211}
212
213func printDashboardItems(w io.Writer, items []dashboardItem, marker string) {
214 for _, item := range items {
215 fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author)
216 }
217}
218
219// feedDefaultLimit caps a bare `feed` call; pagination reaches further
220// back.
221const feedDefaultLimit = 50
222
223type feedOut struct {
224 ID int64 `json:"id"`
225 Repo string `json:"repo"`
226 Actor string `json:"actor,omitempty"`
227 Kind string `json:"kind"`
228 Data json.RawMessage `json:"data,omitempty"`
229 CreatedAt string `json:"created_at"`
230}
231
232func feedOutputs(events []store.FeedEvent) []feedOut {
233 ds := make([]feedOut, 0, len(events))
234 for _, e := range events {
235 d := feedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
236 if json.Valid([]byte(e.Data)) {
237 d.Data = json.RawMessage(e.Data)
238 }
239 ds = append(ds, d)
240 }
241 return ds
242}
243
244func runFeed(c *Ctx, args []string) int {
245 rest, p, code := parsePageFlags(c, args, "feed", true)
246 if code >= 0 {
247 return code
248 }
249 if len(rest) != 0 {
250 return c.fail(protocol.ExitUsage, "usage: feed [--limit <n>] [--cursor <c>]")
251 }
252 if p.limit == 0 {
253 p.limit = feedDefaultLimit
254 }
255 events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt())
256 if err != nil {
257 return c.fail(protocol.ExitFailure, "%v", err)
258 }
259 events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
260 return strconv.FormatInt(e.ID, 10)
261 })
262 ds := feedOutputs(events)
263 return c.emitPage(p, ds, next, func(w io.Writer) {
264 for _, d := range ds {
265 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.CreatedAt, d.Actor, d.Kind, d.Repo, string(d.Data))
266 }
267 })
268}