Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/web/view.go

Raw
package web

import (
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"

	"bugabinga.net/luci/internal/history"
	"bugabinga.net/luci/internal/inbox"
	"bugabinga.net/luci/internal/proquint"
	"bugabinga.net/luci/internal/runstate"
	"bugabinga.net/luci/internal/trigger"
)

type dashboardView struct {
	FailingRepos, RunningRepos, HealthyRepos int
	Attention, Progress, Success             []runView
	Warnings                                 []string
	All, Running                             bool
}
type runView struct {
	RunID, ChildID, Repo, Job, Status, Ref, FullRef, Rev, FullRev, FullHead, FullTail, Trigger, Timing, DurationSeconds, Age, Detail, Search, Started, StartedHuman, Finished, FinishedHuman, Meta, When, Subject, Wait, WaitSeconds, Matrix, Pronounce, PronounceShort, PronounceHead, PronounceTail string
	MatrixValues                                                                                                                                                                                                                                                                                      map[string]string
	SortTime                                                                                                                                                                                                                                                                                          time.Time
	StartedAt, EndedAt                                                                                                                                                                                                                                                                                time.Time
}
type runDetailView struct {
	Known    bool
	Parent   runView
	Children []runView
}

type Dashboard = dashboardView
type Run = runView
type RunDetail = runDetailView

func BuildDashboard(now time.Time, events []history.Event, pending []inbox.Pending, active []runstate.Active, all bool) Dashboard {
	return buildDashboard(now, events, pending, active, all)
}

func BuildRun(runID string, now time.Time, events []history.Event, pending []inbox.Pending, active []runstate.Active) RunDetail {
	return buildRun(runID, now, events, pending, active)
}

func buildDashboard(now time.Time, events []history.Event, pending []inbox.Pending, active []runstate.Active, all bool) dashboardView {
	parents := parentEvents(events)
	latest := map[string]history.Event{}
	for _, event := range parents {
		if previous, ok := latest[event.Repo]; !ok || eventAfter(event, previous) {
			latest[event.Repo] = event
		}
	}
	activeRuns, progressRepos := map[string]runstate.Active{}, map[string]bool{}
	pendingRuns := map[string]inbox.Pending{}
	for _, current := range pending {
		pendingRuns[current.ID] = current
	}
	var progress []runView
	for _, current := range active {
		activeRuns[current.RunID] = current
		progressRepos[current.Repo] = true
		if parent, ok := pendingRuns[current.RunID]; ok {
			progress = append(progress, runningParent(now, parent, current))
		} else {
			progress = append(progress, activeView(now, current))
		}
	}
	for _, current := range pending {
		if _, running := activeRuns[current.ID]; running {
			continue
		}
		v := pendingView(now, current)
		progressRepos[v.Repo] = true
		progress = append(progress, v)
	}
	var attention, success []runView
	for _, event := range latest {
		if event.Status == "failed" {
			attention = append(attention, eventView(now, event))
		}
	}
	for _, event := range parents {
		if event.Status == "success" {
			success = append(success, eventView(now, event))
		}
	}
	repos := map[string]bool{}
	for repo := range latest {
		repos[repo] = true
	}
	for repo := range progressRepos {
		repos[repo] = true
	}
	view := dashboardView{Attention: attention, Progress: progress, Success: success, All: all}
	for repo := range repos {
		terminal, ok := latest[repo]
		switch {
		case ok && terminal.Status == "failed":
			view.FailingRepos++
		case progressRepos[repo]:
			view.RunningRepos++
		case ok && terminal.Status == "success":
			view.HealthyRepos++
		}
	}
	sort.Slice(view.Attention, func(i, j int) bool {
		if view.Attention[i].SortTime.Equal(view.Attention[j].SortTime) {
			return view.Attention[i].RunID > view.Attention[j].RunID
		}
		return view.Attention[i].SortTime.After(view.Attention[j].SortTime)
	})
	sort.Slice(view.Success, func(i, j int) bool {
		if view.Success[i].SortTime.Equal(view.Success[j].SortTime) {
			return view.Success[i].RunID > view.Success[j].RunID
		}
		return view.Success[i].SortTime.After(view.Success[j].SortTime)
	})
	sort.Slice(view.Progress, func(i, j int) bool {
		a, b := submitted(view.Progress[i].RunID), submitted(view.Progress[j].RunID)
		if a.Equal(b) {
			return view.Progress[i].RunID > view.Progress[j].RunID
		}
		return a.After(b)
	})
	if !all {
		view.Attention = limit(view.Attention, 50)
		view.Progress = limit(view.Progress, 50)
		view.Success = limit(view.Success, 50)
	}
	short := buildShortener(events, pending, active)
	for i := range view.Attention {
		short.apply(&view.Attention[i])
	}
	for i := range view.Progress {
		short.apply(&view.Progress[i])
	}
	for i := range view.Success {
		short.apply(&view.Success[i])
	}
	return view
}

func buildRun(runID string, now time.Time, events []history.Event, pending []inbox.Pending, active []runstate.Active) runDetailView {
	var out runDetailView
	explicitParent := false
	var legacy []history.Event
	for _, event := range events {
		if event.RunID != runID {
			continue
		}
		out.Known = true
		view := eventView(now, event)
		switch event.ChildID {
		case "run":
			out.Parent = view
			explicitParent = true
		case "":
			legacy = append(legacy, event)
		default:
			out.Children = append(out.Children, view)
		}
	}
	if !explicitParent && len(legacy) > 0 {
		sort.SliceStable(legacy, func(i, j int) bool {
			if legacy[i].Job == legacy[j].Job {
				return legacy[i].Time.Before(legacy[j].Time)
			}
			return legacy[i].Job < legacy[j].Job
		})
		latestLegacy := legacy[0]
		for i, event := range legacy {
			child := eventView(now, event)
			child.ChildID = fmt.Sprintf("legacy-%03d", i+1)
			out.Children = append(out.Children, child)
			if eventAfter(event, latestLegacy) {
				latestLegacy = event
			}
		}
		out.Parent = eventView(now, latestLegacy)
		out.Parent.ChildID = "run"
	}
	var parentPending *inbox.Pending
	if !explicitParent {
		for i := range pending {
			if pending[i].ID == runID {
				parentPending = &pending[i]
				out.Known = true
				out.Parent = pendingView(now, pending[i])
				break
			}
		}
	}
	for _, current := range active {
		if current.RunID != runID {
			continue
		}
		out.Known = true
		child := activeView(now, current)
		out.Children = append(out.Children, child)
		if !explicitParent {
			if parentPending != nil {
				out.Parent = runningParent(now, *parentPending, current)
			} else {
				out.Parent = child
				out.Parent.ChildID = "run"
			}
		}
	}
	if out.Parent.RunID == "" && len(out.Children) > 0 {
		out.Parent = out.Children[0]
		out.Parent.ChildID = "run"
	}
	if !out.Parent.StartedAt.IsZero() {
		for i := range out.Children {
			if out.Children[i].StartedAt.After(out.Parent.StartedAt) {
				if wait := out.Children[i].StartedAt.Sub(out.Parent.StartedAt); wait > time.Second {
					out.Children[i].Wait = "queued " + compactDuration(wait)
					out.Children[i].WaitSeconds = strconv.FormatInt(int64(wait/time.Second), 10)
				}
			}
		}
	}
	short := buildShortener(events, pending, active)
	short.apply(&out.Parent)
	for i := range out.Children {
		short.apply(&out.Children[i])
	}
	sort.Slice(out.Children, func(i, j int) bool { return out.Children[i].ChildID < out.Children[j].ChildID })
	return out
}

func parentEvents(events []history.Event) []history.Event {
	explicit := map[string]bool{}
	for _, e := range events {
		if e.ChildID == "run" {
			explicit[e.RunID] = true
		}
	}
	var out []history.Event
	for _, e := range events {
		if e.ChildID == "run" || e.ChildID == "" && !explicit[e.RunID] {
			out = append(out, e)
		}
	}
	return out
}
func eventAfter(a, b history.Event) bool {
	if a.Time.Equal(b.Time) {
		return a.RunID > b.RunID
	}
	return a.Time.After(b.Time)
}
func pronounces(id string) string {
	if value, err := strconv.ParseInt(id, 10, 64); err == nil {
		return proquint.Encode(value)
	}
	return ""
}

func eventView(now time.Time, e history.Event) runView {
	v := runView{RunID: e.RunID, Pronounce: pronounces(e.RunID), ChildID: e.ChildID, Repo: e.Repo, Job: e.Job, Status: e.Status, Subject: e.Subject, Ref: shortRef(e.Ref), FullRef: valueOrDash(e.Ref), Rev: shortRev(e.Rev), FullRev: valueOrDash(e.Rev), Trigger: triggerDisplay(e.Trigger), Detail: e.Detail, Matrix: matrixDisplay(e.Matrix), MatrixValues: e.Matrix, Age: age(now, e.Time), Finished: timestamp(e.Time), FinishedHuman: humanTimestamp(e.Time), SortTime: e.Time, StartedAt: e.StartedAt, EndedAt: e.Time}
	if !e.StartedAt.IsZero() && !e.Time.Before(e.StartedAt) {
		duration := e.Time.Sub(e.StartedAt)
		v.Timing = compactDuration(duration)
		v.DurationSeconds = strconv.FormatInt(int64(duration/time.Second), 10)
		v.Started, v.StartedHuman = timestamp(e.StartedAt), humanTimestamp(e.StartedAt)
	} else {
		v.Timing, v.DurationSeconds, v.Started, v.StartedHuman = "—", "—", "—", "—"
	}
	v.Search = strings.ToLower(v.Repo + " " + v.Job + " " + v.Subject)
	v.Meta, v.When = rowMeta(v.Trigger, v.Ref), rowWhen(v.Status, v.Timing, v.Age)
	return v
}
func pendingView(now time.Time, p inbox.Pending) runView {
	repo, job, rev, ref, kind := triggerView(p.Event)
	at := submitted(p.ID)
	v := runView{RunID: p.ID, Pronounce: pronounces(p.ID), ChildID: "run", Repo: repo, Job: job, Status: p.Phase, Ref: shortRef(ref), FullRef: valueOrDash(ref), Rev: shortRev(rev), FullRev: valueOrDash(rev), Trigger: kind, Timing: elapsed(now, at), DurationSeconds: durationSeconds(now, at), Age: elapsed(now, at), Started: timestamp(at), StartedHuman: humanTimestamp(at), SortTime: at}
	v.Search = strings.ToLower(repo + " " + job)
	v.Meta, v.When = rowMeta(v.Trigger, v.Ref), rowWhen(v.Status, v.Timing, v.Age)
	return v
}
func runningParent(now time.Time, pending inbox.Pending, active runstate.Active) runView {
	parent, child := pendingView(now, pending), activeView(now, active)
	parent.Status, parent.Timing, parent.DurationSeconds, parent.Age, parent.Started, parent.StartedHuman = "running", child.Timing, child.DurationSeconds, child.Age, child.Started, child.StartedHuman
	parent.Ref, parent.FullRef, parent.Rev, parent.FullRev = child.Ref, child.FullRef, child.Rev, child.FullRev
	parent.Search = strings.ToLower(parent.Repo + " " + parent.Job)
	parent.Meta, parent.When = rowMeta(parent.Trigger, parent.Ref), rowWhen(parent.Status, parent.Timing, parent.Age)
	return parent
}

func activeView(now time.Time, a runstate.Active) runView {
	v := runView{RunID: a.RunID, Pronounce: pronounces(a.RunID), ChildID: a.ChildID, Repo: a.Repo, Job: a.Job, Status: "running", Ref: shortRef(a.Ref), FullRef: valueOrDash(a.Ref), Rev: shortRev(a.Rev), FullRev: valueOrDash(a.Rev), Trigger: valueOrDash(a.Trigger), Timing: elapsed(now, a.StartedAt), DurationSeconds: durationSeconds(now, a.StartedAt), Age: elapsed(now, a.StartedAt), Started: timestamp(a.StartedAt), StartedHuman: humanTimestamp(a.StartedAt), SortTime: a.StartedAt}
	v.Search = strings.ToLower(v.Repo + " " + v.Job)
	v.Meta, v.When = rowMeta(v.Trigger, v.Ref), rowWhen(v.Status, v.Timing, v.Age)
	return v
}
func triggerView(event trigger.Event) (repo, job, rev, ref, kind string) {
	switch e := event.(type) {
	case trigger.ManualEvent:
		repo, job, kind = e.Repo, e.Job, "manual"
		if e.Rev != nil {
			rev = *e.Rev
		}
		if e.Ref != nil {
			ref = *e.Ref
		}
	case trigger.PushEvent:
		repo, job, rev, ref, kind = e.Repo, "push", e.New, e.Ref, "push"
	case trigger.ScheduleEvent:
		repo, job, rev, ref, kind = e.Repo, e.Job, e.Rev, e.Ref, "schedule"
	}
	return
}
func limit(v []runView, n int) []runView {
	if len(v) > n {
		return v[:n]
	}
	return v
}
func shortRev(v string) string {
	if v == "" {
		return "—"
	}
	if len(v) > 7 {
		return v[:7]
	}
	return v
}
func shortRef(v string) string {
	v = strings.TrimPrefix(v, "refs/heads/")
	v = strings.TrimPrefix(v, "refs/tags/")
	return valueOrDash(v)
}
func valueOrDash(v string) string {
	if v == "" {
		return "—"
	}
	return v
}
func compactDuration(v time.Duration) string {
	if v < 0 {
		return "—"
	}
	switch {
	case v >= 24*time.Hour:
		return fmt.Sprintf("%dd", v/(24*time.Hour))
	case v >= time.Hour:
		return fmt.Sprintf("%dh", v/time.Hour)
	case v >= time.Minute:
		return fmt.Sprintf("%dm", v/time.Minute)
	default:
		return fmt.Sprintf("%ds", v/time.Second)
	}
}
func elapsed(now, then time.Time) string {
	if then.IsZero() || now.Before(then) {
		return "—"
	}
	return compactDuration(now.Sub(then))
}
func age(now, then time.Time) string {
	value := elapsed(now, then)
	if value == "—" {
		return value
	}
	return value + " ago"
}

func rowMeta(trigger, ref string) string {
	if trigger == "unknown" {
		trigger = ""
	}
	if ref == "—" {
		ref = ""
	}
	if trigger == "" {
		return ref
	}
	if ref == "" {
		return trigger
	}
	return trigger + " · " + ref
}
func rowWhen(status, timing, age string) string {
	if status == "queued" || status == "preparing" || status == "running" {
		return timing
	}
	if timing == "—" {
		return age
	}
	if age == "—" {
		return timing
	}
	return timing + " · " + age
}

func triggerDisplay(value string) string {
	if value == "" {
		return "unknown"
	}
	return value
}
func submitted(id string) time.Time {
	n, err := strconv.ParseInt(id, 10, 64)
	if err != nil {
		return time.Time{}
	}
	return time.Unix(0, n).UTC()
}
func timestamp(v time.Time) string {
	if v.IsZero() {
		return "—"
	}
	return v.UTC().Format(time.RFC3339)
}
func humanTimestamp(v time.Time) string {
	if v.IsZero() {
		return "—"
	}
	return v.UTC().Format("2 Jan 2006, 15:04 UTC")
}
func durationSeconds(now, then time.Time) string {
	if then.IsZero() || now.Before(then) {
		return "—"
	}
	return strconv.FormatInt(int64(now.Sub(then)/time.Second), 10)
}
func matrixDisplay(matrix map[string]string) string {
	if len(matrix) == 0 {
		return ""
	}
	keys := make([]string, 0, len(matrix))
	for key := range matrix {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	values := make([]string, 0, len(keys))
	for _, key := range keys {
		values = append(values, key+"="+matrix[key])
	}
	return strings.Join(values, " · ")
}