internal/control/dashboard.go

5e9732fb1d5142142fdae2985f07125cf852647c
gitbay/internal/control/dashboard.go history · blame · raw

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}