internal/control/dashboard.go
286 lines · 9672 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
38// PinnedOut is one pinned repository on the dashboard.
39type PinnedOut struct {
40 Path string `json:"path"`
41 Visibility string `json:"visibility"`
42 Description string `json:"description,omitempty"`
43 Archived bool `json:"archived,omitempty"`
44}
45
46// DashboardBuild is a build with its repository resolved, which is what
47// separates it from BuildOut: the dashboard spans repositories.
48type DashboardBuild 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
59// ServerOut is admin-only. The exact build a host is running narrows down
60// which known issues apply to it, so it is not everyone's to read; the
61// person who needs it is the operator.
62type ServerOut struct {
63 Commit string `json:"commit"`
64}
65
66// DashboardOut is what dashboard emits: the whole account aggregate in
67// one read.
68type DashboardOut struct {
69 Reviews []DashboardItem `json:"review_queue"`
70 Assigned []DashboardItem `json:"assigned_issues"`
71 MRs []DashboardItem `json:"open_mrs"`
72 Issues []DashboardItem `json:"open_issues"`
73 Pinned []PinnedOut `json:"pinned"`
74 Activity []FeedOut `json:"recent_activity"`
75 Builds []DashboardBuild `json:"builds"`
76 // Unread is the notification inbox badge, so a client showing one
77 // does not need a second read to fill it.
78 Unread int `json:"unread"`
79 Server *ServerOut `json:"server,omitempty"`
80 // Queues is admin-only: every background worker's backlog and
81 // failures, the operator's view of what is stuck.
82 Queues *store.Queues `json:"queues,omitempty"`
83}
84
85func runDashboard(c *Ctx, args []string) int {
86 if len(args) != 0 {
87 return c.usage()
88 }
89 d := DashboardOut{
90 Reviews: []DashboardItem{}, Assigned: []DashboardItem{}, MRs: []DashboardItem{},
91 Issues: []DashboardItem{}, Pinned: []PinnedOut{}, Activity: []FeedOut{}, Builds: []DashboardBuild{},
92 }
93
94 pinned, err := c.Store.PinnedRepos(c.User.ID)
95 if err != nil {
96 return c.fail(protocol.ExitFailure, "%v", err)
97 }
98 for _, r := range pinned {
99 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
100 if err != nil {
101 return c.fail(protocol.ExitFailure, "%v", err)
102 }
103 if !policy.CanRead(c.User, r, grant) {
104 continue
105 }
106 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
107 d.Pinned = append(d.Pinned, PinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
108 }
109
110 mrs, err := c.Store.DashboardMRs(c.User.ID)
111 if err != nil {
112 return c.fail(protocol.ExitFailure, "%v", err)
113 }
114 for _, m := range mrs {
115 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
116 }
117
118 reviews, err := c.Store.ReviewQueue(c.User.ID)
119 if err != nil {
120 return c.fail(protocol.ExitFailure, "%v", err)
121 }
122 for _, m := range reviews {
123 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
124 }
125
126 assigned, err := c.Store.AssignedIssues(c.User.ID)
127 if err != nil {
128 return c.fail(protocol.ExitFailure, "%v", err)
129 }
130 for _, i := range assigned {
131 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
132 }
133
134 issues, err := c.Store.DashboardIssues(c.User.ID)
135 if err != nil {
136 return c.fail(protocol.ExitFailure, "%v", err)
137 }
138 for _, i := range issues {
139 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
140 }
141
142 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
143 if err != nil {
144 return c.fail(protocol.ExitFailure, "%v", err)
145 }
146 d.Activity = feedOutputs(events)
147
148 builds, err := c.Store.RecentBuilds(c.User.ID, 20)
149 if err != nil {
150 return c.fail(protocol.ExitFailure, "%v", err)
151 }
152 for _, b := range builds {
153 d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
154 }
155 d.Unread = c.Store.UnreadNotices(c.User.ID)
156 if c.User.IsAdmin {
157 d.Server = &ServerOut{Commit: buildinfo.String()}
158 q, err := c.Store.QueueStatus()
159 if err != nil {
160 return c.fail(protocol.ExitFailure, "%v", err)
161 }
162 d.Queues = &q
163 }
164
165 return c.emit(d, func(w io.Writer) {
166 if d.Unread > 0 {
167 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
168 }
169 fmt.Fprintln(w, "waiting on your review:")
170 printDashboardItems(w, d.Reviews, "!")
171 fmt.Fprintln(w, "assigned to you:")
172 printDashboardItems(w, d.Assigned, "#")
173 fmt.Fprintln(w, "open merge requests:")
174 printDashboardItems(w, d.MRs, "!")
175 fmt.Fprintln(w, "open issues:")
176 printDashboardItems(w, d.Issues, "#")
177 fmt.Fprintln(w, "pinned:")
178 for _, p := range d.Pinned {
179 mark := ""
180 if p.Archived {
181 mark = "\t[archived]"
182 }
183 fmt.Fprintf(w, " %s\t%s\t%s%s\n", p.Path, p.Visibility, p.Description, mark)
184 }
185 fmt.Fprintln(w, "recent activity:")
186 for _, e := range d.Activity {
187 fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", e.CreatedAt, e.Actor, e.Kind, e.Repo, string(e.Data))
188 }
189 fmt.Fprintln(w, "builds:")
190 for _, b := range d.Builds {
191 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)
192 }
193 if d.Server != nil {
194 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit)
195 }
196 if q := d.Queues; q != nil {
197 fmt.Fprintln(w, "queues:")
198 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
199 for _, it := range q.Webhooks.Items {
200 fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Repo, it.URL, it.Attempts, it.LastError)
201 }
202 fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
203 for _, it := range q.Mail.Items {
204 fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Recipient, it.Subject, it.Attempts, it.LastError)
205 }
206 // The device id, not the token: a token is never echoed.
207 fmt.Fprintf(w, " push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed)
208 for _, it := range q.Push.Items {
209 fmt.Fprintf(w, " device %d\t%s\tattempts %d\t%s\n", it.DeviceID, it.Title, it.Attempts, it.LastError)
210 }
211 fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
212 for _, it := range q.Mirrors.Items {
213 fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", it.Repo, it.Direction, it.URL, it.LastError)
214 }
215 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
216 for _, it := range q.Builds.Items {
217 since := it.StartedAt
218 if it.Status == "pending" {
219 since = it.CreatedAt
220 }
221 fmt.Fprintf(w, " %s\t%d\t%s\t%s since %s\n", it.Repo, it.Number, it.Job, it.Status, since)
222 }
223 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
224 for _, it := range q.Deps.Items {
225 fmt.Fprintf(w, " %s\t%s\n", it.Repo, it.LastError)
226 }
227 }
228 })
229}
230
231func printDashboardItems(w io.Writer, items []DashboardItem, marker string) {
232 for _, item := range items {
233 fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author)
234 }
235}
236
237// feedDefaultLimit caps a bare `feed` call; pagination reaches further
238// back.
239const feedDefaultLimit = 50
240
241type FeedOut struct {
242 ID int64 `json:"id"`
243 Repo string `json:"repo"`
244 Actor string `json:"actor,omitempty"`
245 Kind string `json:"kind"`
246 Data json.RawMessage `json:"data,omitempty"`
247 CreatedAt string `json:"created_at"`
248}
249
250func feedOutputs(events []store.FeedEvent) []FeedOut {
251 ds := make([]FeedOut, 0, len(events))
252 for _, e := range events {
253 d := FeedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
254 if json.Valid([]byte(e.Data)) {
255 d.Data = json.RawMessage(e.Data)
256 }
257 ds = append(ds, d)
258 }
259 return ds
260}
261
262func runFeed(c *Ctx, args []string) int {
263 rest, p, code := parsePageFlags(c, args, "feed", true)
264 if code >= 0 {
265 return code
266 }
267 if len(rest) != 0 {
268 return c.usage()
269 }
270 if p.limit == 0 {
271 p.limit = feedDefaultLimit
272 }
273 events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt())
274 if err != nil {
275 return c.fail(protocol.ExitFailure, "%v", err)
276 }
277 events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
278 return strconv.FormatInt(e.ID, 10)
279 })
280 ds := feedOutputs(events)
281 return c.emitPage(p, ds, next, func(w io.Writer) {
282 for _, d := range ds {
283 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.CreatedAt, d.Actor, d.Kind, d.Repo, string(d.Data))
284 }
285 })
286}