internal/control/dashboard.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/control/dashboard.go history · blame · raw

489 lines · 16636 bytes

13 symbols in this file
  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	// Queued marks a merge request with a queued merge.
 44	Queued bool `json:"queued,omitempty"`
 45}
 46
 47// PinnedOut is one pinned repository on the dashboard.
 48type PinnedOut struct {
 49	Path        string `json:"path"`
 50	Visibility  string `json:"visibility"`
 51	Description string `json:"description,omitempty"`
 52	Archived    bool   `json:"archived,omitempty"`
 53}
 54
 55// DashboardBuild is a build with its repository resolved, which is what
 56// separates it from BuildOut: the dashboard spans repositories.
 57type DashboardBuild struct {
 58	Repo       string `json:"repo"`
 59	Number     int64  `json:"number"`
 60	Job        string `json:"job"`
 61	Status     string `json:"status"`
 62	SHA        string `json:"sha"`
 63	Ref        string `json:"ref"`
 64	CreatedAt  string `json:"created_at"`
 65	FinishedAt string `json:"finished_at,omitempty"`
 66}
 67
 68// ServerOut is admin-only. The exact build a host is running narrows down
 69// which known issues apply to it, so it is not everyone's to read; the
 70// person who needs it is the operator.
 71type ServerOut struct {
 72	Commit string `json:"commit"`
 73}
 74
 75// DashboardOut is what dashboard emits: the whole account aggregate in
 76// one read.
 77type DashboardOut struct {
 78	Reviews  []DashboardItem  `json:"review_queue"`
 79	Assigned []DashboardItem  `json:"assigned_issues"`
 80	MRs      []DashboardItem  `json:"open_mrs"`
 81	Issues   []DashboardItem  `json:"open_issues"`
 82	Pinned   []PinnedOut      `json:"pinned"`
 83	Activity []FeedOut        `json:"recent_activity"`
 84	Builds   []DashboardBuild `json:"builds"`
 85	// Queries is each pinned saved query with its first rows.
 86	Queries []DashboardQuery `json:"queries"`
 87	// Unread is the notification inbox badge, so a client showing one
 88	// does not need a second read to fill it.
 89	Unread int        `json:"unread"`
 90	Server *ServerOut `json:"server,omitempty"`
 91	// Queues is admin-only: every background worker's backlog and
 92	// failures, the operator's view of what is stuck.
 93	Queues *store.Queues `json:"queues,omitempty"`
 94}
 95
 96// dashboardActivity is how many feed lines the dashboard shows at a
 97// terminal.
 98const dashboardActivity = 5
 99
100func runDashboard(c *Ctx, args []string) int {
101	if len(args) != 0 {
102		return c.usage()
103	}
104	d := DashboardOut{
105		Reviews: []DashboardItem{}, Assigned: []DashboardItem{}, MRs: []DashboardItem{},
106		Issues: []DashboardItem{}, Pinned: []PinnedOut{}, Activity: []FeedOut{}, Builds: []DashboardBuild{},
107	}
108
109	pinned, err := c.Store.PinnedRepos(c.User.ID)
110	if err != nil {
111		return c.fail(protocol.ExitFailure, "%v", err)
112	}
113	for _, r := range pinned {
114		grant, err := c.Store.AccessRole(r.ID, c.User.ID)
115		if err != nil {
116			return c.fail(protocol.ExitFailure, "%v", err)
117		}
118		if !policy.CanRead(c.User, r, grant) {
119			continue
120		}
121		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
122		d.Pinned = append(d.Pinned, PinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
123	}
124
125	mrs, err := c.Store.DashboardMRs(c.User.ID)
126	if err != nil {
127		return c.fail(protocol.ExitFailure, "%v", err)
128	}
129	for _, m := range mrs {
130		d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
131	}
132
133	reviews, err := c.Store.ReviewQueue(c.User.ID)
134	if err != nil {
135		return c.fail(protocol.ExitFailure, "%v", err)
136	}
137	for _, m := range reviews {
138		d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
139	}
140
141	assigned, err := c.Store.AssignedIssues(c.User.ID)
142	if err != nil {
143		return c.fail(protocol.ExitFailure, "%v", err)
144	}
145	for _, i := range assigned {
146		d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
147	}
148
149	issues, err := c.Store.DashboardIssues(c.User.ID)
150	if err != nil {
151		return c.fail(protocol.ExitFailure, "%v", err)
152	}
153	for _, i := range issues {
154		d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
155	}
156
157	events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
158	if err != nil {
159		return c.fail(protocol.ExitFailure, "%v", err)
160	}
161	d.Activity = feedOutputs(events)
162
163	builds, err := c.Store.RecentBuilds(c.User.ID, 20)
164	if err != nil {
165		return c.fail(protocol.ExitFailure, "%v", err)
166	}
167	for _, b := range builds {
168		d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
169	}
170	if d.Queries, err = PinnedQueries(c.Store, c.User); err != nil {
171		return c.fail(protocol.ExitFailure, "%v", err)
172	}
173	d.Unread = c.Store.UnreadNotices(c.User.ID)
174	if c.User.IsAdmin {
175		d.Server = &ServerOut{Commit: buildinfo.String()}
176		q, err := c.Store.QueueStatus()
177		if err != nil {
178			return c.fail(protocol.ExitFailure, "%v", err)
179		}
180		d.Queues = &q
181	}
182
183	lines := FeedLines(events)
184	return c.emitView(d, func(w io.Writer) {
185		heading := func(title string) { fmt.Fprintln(w, title) }
186		section := func(title string, rows [][]cell) {
187			heading(title)
188			if len(rows) == 0 {
189				fmt.Fprintln(w, "  none")
190				return
191			}
192			for _, r := range rows {
193				parts := make([]string, len(r))
194				for i, cl := range r {
195					parts[i] = cl.s
196					if cl.kind == kindAge {
197						parts[i] = stamp(cl.s)
198					}
199				}
200				fmt.Fprintf(w, "  %s\n", strings.Join(parts, "\t"))
201			}
202		}
203		itemRows := func(items []DashboardItem, marker string) [][]cell {
204			rows := make([][]cell, len(items))
205			for i, item := range items {
206				page := "issues"
207				if marker == "!" {
208					page = "mrs"
209				}
210				ref := cLink(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number), c.siteURL(item.Repo, page, strconv.FormatInt(item.Number, 10)))
211				rows[i] = []cell{ref, cFlex(item.Title), cText(item.Author)}
212			}
213			return rows
214		}
215
216		if d.Unread > 0 {
217			fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
218		}
219		section("waiting on your review:", itemRows(d.Reviews, "!"))
220		section("assigned to you:", itemRows(d.Assigned, "#"))
221		section("open merge requests:", itemRows(d.MRs, "!"))
222		section("open issues:", itemRows(d.Issues, "#"))
223		for _, q := range d.Queries {
224			rows := make([][]cell, 0, len(q.Items))
225			for _, it := range q.Items {
226				rows = append(rows, []cell{cRef(it.Ref()), cFlex(it.Title), cText(it.Author)})
227			}
228			title := fmt.Sprintf("query %s (%d):", q.Name, q.Count)
229			if q.Error != "" {
230				title = fmt.Sprintf("query %s: %s", q.Name, q.Error)
231			}
232			section(title, rows)
233		}
234
235		pinnedRows := make([][]cell, len(d.Pinned))
236		for i, p := range d.Pinned {
237			cells := []cell{cLink(p.Path, c.siteURL(p.Path)), cState(p.Visibility), cFlex(p.Description)}
238			if p.Archived {
239				cells = c.note(cells, 1, "[archived]", "archived")
240			}
241			pinnedRows[i] = cells
242		}
243		section("pinned:", pinnedRows)
244
245		activityRows := make([][]cell, len(lines))
246		for i, l := range lines {
247			activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
248		}
249		section("recent activity:", activityRows)
250
251		buildRows := make([][]cell, len(d.Builds))
252		for i, b := range d.Builds {
253			buildRows[i] = []cell{cLink(b.Repo, c.siteURL(b.Repo, "builds", strconv.FormatInt(b.Number, 10))), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)}
254		}
255		section("builds:", buildRows)
256
257		if d.Server != nil {
258			heading("server:")
259			fmt.Fprintf(w, "  build %s\n", d.Server.Commit)
260		}
261		if q := d.Queues; q != nil {
262			heading("queues:")
263
264			fmt.Fprintf(w, "  webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
265			twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR")
266			for _, it := range q.Webhooks.Items {
267				twh.row(cRef("    "+it.Repo), cText(it.URL), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
268			}
269			twh.flush()
270
271			fmt.Fprintf(w, "  mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
272			tma := c.table(w, "RECIPIENT", "SUBJECT", "ATTEMPTS", "ERROR")
273			for _, it := range q.Mail.Items {
274				tma.row(cRef("    "+it.Recipient), cText(it.Subject), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
275			}
276			tma.flush()
277
278			// The device id, not the token: a token is never echoed.
279			fmt.Fprintf(w, "  push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed)
280			tpu := c.table(w, "DEVICE", "TITLE", "ATTEMPTS", "ERROR")
281			for _, it := range q.Push.Items {
282				tpu.row(cRef(fmt.Sprintf("    device %d", it.DeviceID)), cText(it.Title), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
283			}
284			tpu.flush()
285
286			fmt.Fprintf(w, "  mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
287			tmi := c.table(w, "REPO", "DIRECTION", "URL", "ERROR")
288			for _, it := range q.Mirrors.Items {
289				tmi.row(cRef("    "+it.Repo), cText(it.Direction), cText(it.URL), cText(it.LastError))
290			}
291			tmi.flush()
292
293			fmt.Fprintf(w, "  builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
294			tbq := c.table(w, "REPO", "#", "JOB", "STATUS")
295			for _, it := range q.Builds.Items {
296				since := it.StartedAt
297				if it.Status == "pending" {
298					since = it.CreatedAt
299				}
300				since = stamp(since)
301				tbq.row(cRef("    "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since)))
302			}
303			tbq.flush()
304
305			fmt.Fprintf(w, "  deps\terrors %d\n", q.Deps.Errors)
306			tde := c.table(w, "REPO", "ERROR")
307			for _, it := range q.Deps.Items {
308				tde.row(cRef("    "+it.Repo), cText(it.LastError))
309			}
310			tde.flush()
311		}
312	}, func() screen { return dashboardScreen(c, d, lines) })
313}
314
315// feedDefaultLimit caps a bare `feed` call; pagination reaches further
316// back.
317const feedDefaultLimit = 50
318
319type FeedOut struct {
320	ID        int64           `json:"id"`
321	Repo      string          `json:"repo"`
322	Actor     string          `json:"actor,omitempty"`
323	Kind      string          `json:"kind"`
324	Data      json.RawMessage `json:"data,omitempty"`
325	CreatedAt string          `json:"created_at"`
326}
327
328func feedOutputs(events []store.FeedEvent) []FeedOut {
329	ds := make([]FeedOut, 0, len(events))
330	for _, e := range events {
331		d := FeedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
332		if json.Valid([]byte(e.Data)) {
333			d.Data = json.RawMessage(e.Data)
334		}
335		ds = append(ds, d)
336	}
337	return ds
338}
339
340func runFeed(c *Ctx, args []string) int {
341	rest, p, code := parsePageFlags(c, args, "feed", true)
342	if code >= 0 {
343		return code
344	}
345	if len(rest) != 0 {
346		return c.usage()
347	}
348	if p.limit == 0 {
349		p.limit = feedDefaultLimit
350	}
351	events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt())
352	if err != nil {
353		return c.fail(protocol.ExitFailure, "%v", err)
354	}
355	events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
356		return strconv.FormatInt(e.ID, 10)
357	})
358	ds := feedOutputs(events)
359	lines := FeedLines(events)
360	return c.emitPageView(p, ds, next, func(w io.Writer) {
361		tb := c.table(w, "WHEN", "EVENT")
362		for _, l := range lines {
363			tb.row(cAge(l.When), cFlex(l.Sentence()))
364		}
365		tb.flush()
366	}, func() screen {
367		rows := make([]row, len(lines))
368		for i, l := range lines {
369			rows[i] = rowOf(l.termCells(c)...)
370		}
371		return listScreen("Activity", rows)
372	})
373}
374
375// dashboardScreen is dashboard at a terminal: what waits on the viewer
376// first, then open merge requests, failed builds, a few lines of
377// activity and the pinned repositories. The operator's queues are
378// admin stats'; a background failure shows as one header line.
379func dashboardScreen(c *Ctx, d DashboardOut, lines []FeedLine) screen {
380	var s screen
381	s.fields = append(s.fields, field{"User", []cell{cText(c.User.Username)}})
382	if host := c.Cfg.SiteHost(); host != "" {
383		s.fields = append(s.fields, field{"Instance", []cell{cText(host)}})
384	}
385	if q := d.Queues; q != nil {
386		if bad := q.Webhooks.Failed + q.Mail.Failed + q.Push.Failed + q.Mirrors.Errors + q.Deps.Errors; bad > 0 {
387			s.fields = append(s.fields, field{"Problems", []cell{cGlyph("failed"), cText(fmt.Sprintf("%d failing in the background", bad))}})
388		}
389	}
390	if d.Unread > 0 {
391		s.fields = append(s.fields, field{"Inbox", []cell{cYou(), cText(fmt.Sprintf("%d unread", d.Unread))}})
392	}
393
394	item := func(it DashboardItem, marker, page string, you bool) row {
395		lead := cell{kind: kindGlyph}
396		if you {
397			lead = cYou()
398		}
399		ref := cLink(fmt.Sprintf("%s%s%d", it.Repo, marker, it.Number), c.siteURL(it.Repo, page, strconv.FormatInt(it.Number, 10)))
400		return rowOf(ref, lead, cFlex(it.Title), cMeta(it.Author))
401	}
402	items := func(title string, its []DashboardItem, marker, page string, you bool) section {
403		sec := section{title: title, n: len(its)}
404		for _, it := range its {
405			sec.rows = append(sec.rows, item(it, marker, page, you))
406		}
407		return sec
408	}
409	s.sections = append(s.sections,
410		items("Review requested", d.Reviews, "!", "mrs", true),
411		items("Assigned issues", d.Assigned, "#", "issues", true),
412		items("Open merge requests", d.MRs, "!", "mrs", false),
413	)
414	for _, q := range d.Queries {
415		sec := section{title: q.Name, n: q.Count, more: []string{"query", "run", q.Name}}
416		if q.Error != "" {
417			sec.note, sec.empty = q.Error, true
418		}
419		for _, it := range q.Items {
420			sec.rows = append(sec.rows, rowOf(cRef(it.Ref()), cFlex(it.Title), cMeta(it.Author)))
421		}
422		s.sections = append(s.sections, sec)
423	}
424
425	// A job's latest build is the one that counts: a failure a later
426	// build of the same job and ref has replaced is not shown.
427	failed := section{title: "Failed builds"}
428	passed := 0
429	seen := map[string]bool{}
430	var firstFailed *DashboardBuild
431	for i, b := range d.Builds {
432		key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
433		if seen[key] {
434			continue
435		}
436		seen[key] = true
437		switch b.Status {
438		case "success":
439			passed++
440		case "failure", "error":
441			n := strconv.FormatInt(b.Number, 10)
442			failed.n++
443			failed.rows = append(failed.rows, rowOf(cLink(n, c.siteURL(b.Repo, "builds", n)), cGlyph(b.Status),
444				cFlex(b.Job+"  "+b.Ref), cMeta(b.Repo, relAge(b.CreatedAt, termNow()))))
445			if firstFailed == nil {
446				firstFailed = &d.Builds[i]
447			}
448		}
449	}
450	s.sections = append(s.sections, failed)
451
452	activity := section{title: "Recent activity", n: len(lines), more: []string{"feed"}}
453	for _, l := range lines[:min(len(lines), dashboardActivity)] {
454		activity.rows = append(activity.rows, rowOf(l.termCells(c)...))
455	}
456	if passed > 0 {
457		word := "builds"
458		if passed == 1 {
459			word = "build"
460		}
461		activity.note, activity.empty = fmt.Sprintf("%d %s passed", passed, word), true
462	}
463	s.sections = append(s.sections, activity)
464
465	if len(d.Pinned) > 0 {
466		paths := make([]string, len(d.Pinned))
467		for i, p := range d.Pinned {
468			paths[i] = p.Path
469		}
470		s.sections = append(s.sections, section{title: "Pinned", n: len(d.Pinned), rows: []row{rowOf(cMeta(strings.Join(paths, "  ")))}})
471	}
472
473	if len(d.Reviews) > 0 {
474		it := d.Reviews[0]
475		s.actions = append(s.actions, action{"Next", []string{"mr", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
476	}
477	if len(d.Assigned) > 0 {
478		it := d.Assigned[0]
479		s.actions = append(s.actions, action{"Next", []string{"issue", "show", it.Repo, strconv.FormatInt(it.Number, 10)}})
480	}
481	if firstFailed != nil {
482		s.actions = append(s.actions, action{"Next", []string{"build", "log", firstFailed.Repo, strconv.FormatInt(firstFailed.Number, 10)}})
483	}
484	s.actions = append(s.actions, action{"More", []string{"feed"}})
485	if c.User.IsAdmin {
486		s.actions = append(s.actions, action{"Instance", []string{"admin", "stats"}})
487	}
488	return s
489}