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, " · ") }