internal/control/dashboard.go

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

459 lines · 14830 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	// 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 = 8
 99
100// needsYou is the dashboard's first line at a terminal: what waits on
101// the viewer, in yellow, or a dim line saying nothing does.
102func (t Term) needsYou(d DashboardOut) string {
103	var parts []string
104	add := func(n int, one, many string) {
105		switch {
106		case n == 1:
107			parts = append(parts, "1 "+one)
108		case n > 1:
109			parts = append(parts, fmt.Sprintf("%d %s", n, many))
110		}
111	}
112	add(len(d.Reviews), "review requested", "reviews requested")
113	add(len(d.Assigned), "assigned issue", "assigned issues")
114	add(failingBuilds(d.Builds), "failing build", "failing builds")
115	add(d.Unread, "unread notification", "unread notifications")
116	if len(parts) == 0 {
117		return t.paint(sgrDim, "Nothing waits on you.")
118	}
119	return t.paint(sgrBold+sgrYellow, "Needs you: "+strings.Join(parts, ", "))
120}
121
122// failingBuilds counts the jobs whose latest build failed: a failure a
123// later build of the same job and ref has already replaced is not one.
124func failingBuilds(builds []DashboardBuild) int {
125	seen := map[string]bool{}
126	n := 0
127	for _, b := range builds {
128		key := b.Repo + "\x00" + b.Job + "\x00" + b.Ref
129		if seen[key] {
130			continue
131		}
132		seen[key] = true
133		if b.Status == "failure" {
134			n++
135		}
136	}
137	return n
138}
139
140func runDashboard(c *Ctx, args []string) int {
141	if len(args) != 0 {
142		return c.usage()
143	}
144	d := DashboardOut{
145		Reviews: []DashboardItem{}, Assigned: []DashboardItem{}, MRs: []DashboardItem{},
146		Issues: []DashboardItem{}, Pinned: []PinnedOut{}, Activity: []FeedOut{}, Builds: []DashboardBuild{},
147	}
148
149	pinned, err := c.Store.PinnedRepos(c.User.ID)
150	if err != nil {
151		return c.fail(protocol.ExitFailure, "%v", err)
152	}
153	for _, r := range pinned {
154		grant, err := c.Store.AccessRole(r.ID, c.User.ID)
155		if err != nil {
156			return c.fail(protocol.ExitFailure, "%v", err)
157		}
158		if !policy.CanRead(c.User, r, grant) {
159			continue
160		}
161		desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
162		d.Pinned = append(d.Pinned, PinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
163	}
164
165	mrs, err := c.Store.DashboardMRs(c.User.ID)
166	if err != nil {
167		return c.fail(protocol.ExitFailure, "%v", err)
168	}
169	for _, m := range mrs {
170		d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
171	}
172
173	reviews, err := c.Store.ReviewQueue(c.User.ID)
174	if err != nil {
175		return c.fail(protocol.ExitFailure, "%v", err)
176	}
177	for _, m := range reviews {
178		d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
179	}
180
181	assigned, err := c.Store.AssignedIssues(c.User.ID)
182	if err != nil {
183		return c.fail(protocol.ExitFailure, "%v", err)
184	}
185	for _, i := range assigned {
186		d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
187	}
188
189	issues, err := c.Store.DashboardIssues(c.User.ID)
190	if err != nil {
191		return c.fail(protocol.ExitFailure, "%v", err)
192	}
193	for _, i := range issues {
194		d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
195	}
196
197	events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
198	if err != nil {
199		return c.fail(protocol.ExitFailure, "%v", err)
200	}
201	d.Activity = feedOutputs(events)
202
203	builds, err := c.Store.RecentBuilds(c.User.ID, 20)
204	if err != nil {
205		return c.fail(protocol.ExitFailure, "%v", err)
206	}
207	for _, b := range builds {
208		d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
209	}
210	if d.Queries, err = PinnedQueries(c.Store, c.User); err != nil {
211		return c.fail(protocol.ExitFailure, "%v", err)
212	}
213	d.Unread = c.Store.UnreadNotices(c.User.ID)
214	if c.User.IsAdmin {
215		d.Server = &ServerOut{Commit: buildinfo.String()}
216		q, err := c.Store.QueueStatus()
217		if err != nil {
218			return c.fail(protocol.ExitFailure, "%v", err)
219		}
220		d.Queues = &q
221	}
222
223	return c.emit(d, func(w io.Writer) {
224		// At a terminal sections are separated by a blank line, and an
225		// empty one is left out.
226		wrote := false
227		heading := func(title string) {
228			if c.Term.Cols == 0 {
229				fmt.Fprintln(w, title)
230				return
231			}
232			if wrote {
233				fmt.Fprintln(w)
234			}
235			wrote = true
236			fmt.Fprintln(w, c.Term.heading(title))
237		}
238		section := func(title string, header []string, rows [][]cell) {
239			if c.Term.Cols > 0 && len(rows) == 0 {
240				return
241			}
242			heading(title)
243			if len(rows) == 0 {
244				fmt.Fprintln(w, "  none")
245				return
246			}
247			if c.Term.Cols == 0 {
248				for _, r := range rows {
249					parts := make([]string, len(r))
250					for i, cl := range r {
251						parts[i] = cl.s
252						if cl.kind == kindAge {
253							parts[i] = stamp(cl.s)
254						}
255					}
256					fmt.Fprintf(w, "  %s\n", strings.Join(parts, "\t"))
257				}
258				return
259			}
260			tb := c.table(w, header...)
261			for _, r := range rows {
262				tb.row(r...)
263			}
264			tb.flush()
265		}
266		itemRows := func(items []DashboardItem, marker string) [][]cell {
267			rows := make([][]cell, len(items))
268			for i, item := range items {
269				page := "issues"
270				if marker == "!" {
271					page = "mrs"
272				}
273				ref := cLink(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number), c.siteURL(item.Repo, page, strconv.FormatInt(item.Number, 10)))
274				rows[i] = []cell{ref, cFlex(item.Title), cText(item.Author)}
275			}
276			return rows
277		}
278
279		if c.Term.Cols > 0 {
280			fmt.Fprintln(w, c.Term.needsYou(d))
281			wrote = true
282		} else if d.Unread > 0 {
283			fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
284		}
285		itemHeader := []string{"REF", "TITLE", "AUTHOR"}
286		section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!"))
287		section("assigned to you:", itemHeader, itemRows(d.Assigned, "#"))
288		section("open merge requests:", itemHeader, itemRows(d.MRs, "!"))
289		section("open issues:", itemHeader, itemRows(d.Issues, "#"))
290		for _, q := range d.Queries {
291			rows := make([][]cell, 0, len(q.Items))
292			for _, it := range q.Items {
293				rows = append(rows, []cell{cRef(it.Ref()), cFlex(it.Title), cText(it.Author)})
294			}
295			title := fmt.Sprintf("query %s (%d):", q.Name, q.Count)
296			if q.Error != "" {
297				title = fmt.Sprintf("query %s: %s", q.Name, q.Error)
298			}
299			section(title, itemHeader, rows)
300		}
301
302		pinnedRows := make([][]cell, len(d.Pinned))
303		for i, p := range d.Pinned {
304			cells := []cell{cLink(p.Path, c.siteURL(p.Path)), cState(p.Visibility), cFlex(p.Description)}
305			if p.Archived {
306				cells = c.note(cells, 1, "[archived]", "archived")
307			}
308			pinnedRows[i] = cells
309		}
310		section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows)
311
312		lines := FeedLines(events)
313		if c.Term.Cols > 0 {
314			// The feed has its own command; the dashboard shows the start.
315			rows := make([][]cell, 0, dashboardActivity)
316			for _, l := range lines[:min(len(lines), dashboardActivity)] {
317				rows = append(rows, l.termCells(c))
318			}
319			section("recent activity:", feedHeader, rows)
320			if len(lines) > dashboardActivity {
321				fmt.Fprintln(w, c.Term.paint(sgrDim, "more: gitbay feed"))
322			}
323		} else {
324			activityRows := make([][]cell, len(lines))
325			for i, l := range lines {
326				activityRows[i] = []cell{cAge(l.When), cFlex(l.Sentence())}
327			}
328			section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
329		}
330
331		buildRows := make([][]cell, len(d.Builds))
332		for i, b := range d.Builds {
333			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)}
334		}
335		section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
336
337		if d.Server != nil {
338			heading("server:")
339			fmt.Fprintf(w, "  build %s\n", d.Server.Commit)
340		}
341		if q := d.Queues; q != nil {
342			heading("queues:")
343
344			fmt.Fprintf(w, "  webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
345			twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR")
346			for _, it := range q.Webhooks.Items {
347				twh.row(cRef("    "+it.Repo), cText(it.URL), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
348			}
349			twh.flush()
350
351			fmt.Fprintf(w, "  mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
352			tma := c.table(w, "RECIPIENT", "SUBJECT", "ATTEMPTS", "ERROR")
353			for _, it := range q.Mail.Items {
354				tma.row(cRef("    "+it.Recipient), cText(it.Subject), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
355			}
356			tma.flush()
357
358			// The device id, not the token: a token is never echoed.
359			fmt.Fprintf(w, "  push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed)
360			tpu := c.table(w, "DEVICE", "TITLE", "ATTEMPTS", "ERROR")
361			for _, it := range q.Push.Items {
362				tpu.row(cRef(fmt.Sprintf("    device %d", it.DeviceID)), cText(it.Title), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
363			}
364			tpu.flush()
365
366			fmt.Fprintf(w, "  mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
367			tmi := c.table(w, "REPO", "DIRECTION", "URL", "ERROR")
368			for _, it := range q.Mirrors.Items {
369				tmi.row(cRef("    "+it.Repo), cText(it.Direction), cText(it.URL), cText(it.LastError))
370			}
371			tmi.flush()
372
373			fmt.Fprintf(w, "  builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
374			tbq := c.table(w, "REPO", "#", "JOB", "STATUS")
375			for _, it := range q.Builds.Items {
376				since := it.StartedAt
377				if it.Status == "pending" {
378					since = it.CreatedAt
379				}
380				if c.Term.Cols == 0 {
381					since = stamp(since)
382				} else {
383					since = relAge(since, termNow())
384				}
385				tbq.row(cRef("    "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since)))
386			}
387			tbq.flush()
388
389			fmt.Fprintf(w, "  deps\terrors %d\n", q.Deps.Errors)
390			tde := c.table(w, "REPO", "ERROR")
391			for _, it := range q.Deps.Items {
392				tde.row(cRef("    "+it.Repo), cText(it.LastError))
393			}
394			tde.flush()
395		}
396	})
397}
398
399// feedDefaultLimit caps a bare `feed` call; pagination reaches further
400// back.
401const feedDefaultLimit = 50
402
403type FeedOut struct {
404	ID        int64           `json:"id"`
405	Repo      string          `json:"repo"`
406	Actor     string          `json:"actor,omitempty"`
407	Kind      string          `json:"kind"`
408	Data      json.RawMessage `json:"data,omitempty"`
409	CreatedAt string          `json:"created_at"`
410}
411
412func feedOutputs(events []store.FeedEvent) []FeedOut {
413	ds := make([]FeedOut, 0, len(events))
414	for _, e := range events {
415		d := FeedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
416		if json.Valid([]byte(e.Data)) {
417			d.Data = json.RawMessage(e.Data)
418		}
419		ds = append(ds, d)
420	}
421	return ds
422}
423
424func runFeed(c *Ctx, args []string) int {
425	rest, p, code := parsePageFlags(c, args, "feed", true)
426	if code >= 0 {
427		return code
428	}
429	if len(rest) != 0 {
430		return c.usage()
431	}
432	if p.limit == 0 {
433		p.limit = feedDefaultLimit
434	}
435	events, err := c.Store.RecentEvents(c.User.ID, p.queryLimit(), p.keyInt())
436	if err != nil {
437		return c.fail(protocol.ExitFailure, "%v", err)
438	}
439	events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
440		return strconv.FormatInt(e.ID, 10)
441	})
442	ds := feedOutputs(events)
443	lines := FeedLines(events)
444	return c.emitPage(p, ds, next, func(w io.Writer) {
445		if c.Term.Cols > 0 {
446			tb := c.table(w, feedHeader...)
447			for _, l := range lines {
448				tb.row(l.termCells(c)...)
449			}
450			tb.flush()
451			return
452		}
453		tb := c.table(w, "WHEN", "EVENT")
454		for _, l := range lines {
455			tb.row(cAge(l.When), cFlex(l.Sentence()))
456		}
457		tb.flush()
458	})
459}