internal/control/dashboard.go

v1.12.0
gitbay/internal/control/dashboard.go history · blame · raw

277 lines · 9347 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.fail(protocol.ExitUsage, "usage: dashboard")
 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			fmt.Fprintf(w, "  mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
207			for _, it := range q.Mirrors.Items {
208				fmt.Fprintf(w, "    %s\t%s\t%s\t%s\n", it.Repo, it.Direction, it.URL, it.LastError)
209			}
210			fmt.Fprintf(w, "  builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
211			for _, it := range q.Builds.Items {
212				fmt.Fprintf(w, "    %s\t%d\t%s\tsince %s\n", it.Repo, it.Number, it.Job, it.StartedAt)
213			}
214			fmt.Fprintf(w, "  deps\terrors %d\n", q.Deps.Errors)
215			for _, it := range q.Deps.Items {
216				fmt.Fprintf(w, "    %s\t%s\n", it.Repo, it.LastError)
217			}
218		}
219	})
220}
221
222func printDashboardItems(w io.Writer, items []DashboardItem, marker string) {
223	for _, item := range items {
224		fmt.Fprintf(w, "  %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author)
225	}
226}
227
228// feedDefaultLimit caps a bare `feed` call; pagination reaches further
229// back.
230const feedDefaultLimit = 50
231
232type FeedOut struct {
233	ID        int64           `json:"id"`
234	Repo      string          `json:"repo"`
235	Actor     string          `json:"actor,omitempty"`
236	Kind      string          `json:"kind"`
237	Data      json.RawMessage `json:"data,omitempty"`
238	CreatedAt string          `json:"created_at"`
239}
240
241func feedOutputs(events []store.FeedEvent) []FeedOut {
242	ds := make([]FeedOut, 0, len(events))
243	for _, e := range events {
244		d := FeedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
245		if json.Valid([]byte(e.Data)) {
246			d.Data = json.RawMessage(e.Data)
247		}
248		ds = append(ds, d)
249	}
250	return ds
251}
252
253func runFeed(c *Ctx, args []string) int {
254	rest, p, code := parsePageFlags(c, args, "feed", true)
255	if code >= 0 {
256		return code
257	}
258	if len(rest) != 0 {
259		return c.fail(protocol.ExitUsage, "usage: feed [--limit <n>] [--cursor <c>]")
260	}
261	if p.limit == 0 {
262		p.limit = feedDefaultLimit
263	}
264	events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt())
265	if err != nil {
266		return c.fail(protocol.ExitFailure, "%v", err)
267	}
268	events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
269		return strconv.FormatInt(e.ID, 10)
270	})
271	ds := feedOutputs(events)
272	return c.emitPage(p, ds, next, func(w io.Writer) {
273		for _, d := range ds {
274			fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.CreatedAt, d.Actor, d.Kind, d.Repo, string(d.Data))
275		}
276	})
277}