internal/control/dashboard.go

1ed9fb9399b21da8e6cf45be8389792aac82d5bc
gitbay/internal/control/dashboard.go history · blame · raw

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