blob: 64f892cf20f453b0837a7111bf37ace99c37d205 [file] [log] [blame]
// Copyright 2015 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:generate stringer -type=ComponentType
package ui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"regexp"
"strings"
"time"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/milo/common/model"
)
// StepDisplayPref is the display preference for the steps.
type StepDisplayPref string
const (
// StepDisplayDefault means that all steps are visible, green steps are
// collapsed.
StepDisplayDefault StepDisplayPref = "default"
// StepDisplayExpanded means that all steps are visible, nested steps are
// expanded.
StepDisplayExpanded StepDisplayPref = "expanded"
// StepDisplayNonGreen means that only non-green steps are visible, nested
// steps are expanded.
StepDisplayNonGreen StepDisplayPref = "non-green"
)
// MiloBuildLegacy denotes a full renderable Milo build page.
// This is slated to be deprecated in April 2019 after the BuildBot turndown.
// This is to be replaced by a new BuildPage struct,
// which encapsulates a Buildbucket Build Proto.
type MiloBuildLegacy struct {
// Summary is a top level summary of the page.
Summary BuildComponent
// Trigger gives information about how and with what information
// the build was triggered.
Trigger *Trigger
// Components is a detailed list of components and subcomponents of the page.
// This is most often used for steps (buildbot/luci) or deps (luci).
Components []*BuildComponent
// PropertyGroup is a list of input and output property of this page.
// This is used for build and emitted properties (buildbot) and quest
// information (luci). This is also grouped by the "type" of property
// so different categories of properties can be separated and sorted.
//
// This is not a map so code that constructs MiloBuildLegacy can control the
// order of property groups, for example show important properties
// first.
PropertyGroup []*PropertyGroup
// Blame is a list of people and commits that is likely to be in relation to
// the thing displayed on this page.
Blame []*Commit
// Mode to render the steps.
StepDisplayPref StepDisplayPref
}
var statusPrecendence = map[model.Status]int{
model.Cancelled: 0,
model.Expired: 1,
model.Exception: 2,
model.InfraFailure: 3,
model.Failure: 4,
model.Warning: 5,
model.Success: 6,
}
// fixComponent fixes possible display inconsistencies with the build, including:
//
// * If the build is complete, all open steps should be closed.
// * Parent steps containing failed steps should also be marked as failed.
// * Components' Collapsed field is set based on StepDisplayPref field.
// * Parent step name prefix is trimmed from the nested substeps.
// * Enforce correct values for StepDisplayPref (set to Collapsed if incorrect).
func fixComponent(comp *BuildComponent, buildFinished time.Time, stripPrefix string, collapseGreen bool) {
// If the build is finished but the step is not finished.
if !buildFinished.IsZero() && comp.ExecutionTime.Finished.IsZero() {
// Then set the finish time to be the same as the build finish time.
comp.ExecutionTime.Finished = buildFinished
comp.ExecutionTime.Duration = buildFinished.Sub(comp.ExecutionTime.Started)
comp.Status = model.InfraFailure
}
// Fix substeps recursively.
for _, substep := range comp.Children {
fixComponent(
substep, buildFinished, comp.Label.String()+".", collapseGreen)
}
// When parent step finishes running, compute its final status as worst
// status, as determined by statusPrecendence map above, among direct children
// and its own status.
if comp.Status.Terminal() {
for _, substep := range comp.Children {
substepStatusPrecedence, ok := statusPrecendence[substep.Status]
if ok && substepStatusPrecedence < statusPrecendence[comp.Status] {
comp.Status = substep.Status
}
}
}
comp.Collapsed = collapseGreen && comp.Status == model.Success
// Strip parent component name from the title.
if comp.Label != nil {
comp.Label.Label = strings.TrimPrefix(comp.Label.Label, stripPrefix)
}
}
var (
farFutureTime = time.Date(2038, time.January, 19, 3, 14, 07, 0, time.UTC)
farPastTime = time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
)
// fixComponentDuration makes all parent steps have the max duration of all
// of its children.
func fixComponentDuration(c context.Context, comp *BuildComponent) Interval {
// Leaf nodes do not require fixing.
if len(comp.Children) == 0 {
return comp.ExecutionTime
}
// Set start and end times to be out of bounds.
// Each variable can have 3 states:
// 1. Undefined. In which case they're set to farFuture/PastTime
// 2. Current (fin only). In which case it's set to zero time (time.Time{})
// 3. Definite. In which case it's set to a time that isn't either of the above.
start := farFutureTime
fin := farPastTime
for _, subcomp := range comp.Children {
i := fixComponentDuration(c, subcomp)
if i.Started.Before(start) {
start = i.Started
}
switch {
case fin.IsZero():
continue // fin is current, it can't get any farther in the future than that.
case i.Finished.IsZero(), i.Finished.After(fin):
fin = i.Finished // Both of these cased are considered "after".
}
}
comp.ExecutionTime = NewInterval(c, start, fin)
return comp.ExecutionTime
}
// Fix fixes various inconsistencies that users expect to see as part of the
// Build, but didn't make sense as part of the individual components, including:
// * If the build is complete, all open steps should be closed.
// * Parent steps containing failed steps should also be marked as failed.
// * Components' Collapsed field is set based on StepDisplayPref field.
// * Parent step name prefix is trimmed from the nested substeps.
// * Enforce correct values for StepDisplayPref (set to Default if incorrect).
// * Set parent step durations to be the combination of all children.
func (b *MiloBuildLegacy) Fix(c context.Context) {
if b.StepDisplayPref != StepDisplayExpanded && b.StepDisplayPref != StepDisplayNonGreen {
b.StepDisplayPref = StepDisplayDefault
}
for _, comp := range b.Components {
fixComponent(
comp, b.Summary.ExecutionTime.Finished, "",
b.StepDisplayPref == StepDisplayDefault)
// Run duration fixing after component fixing to make sure all of the
// end times are set correctly.
fixComponentDuration(c, comp)
}
}
// BuildSummary returns the BuildSummary representation of the MiloBuildLegacy. This
// is the subset of fields that is interesting to the builder view.
func (b *MiloBuildLegacy) BuildSummary() *BuildSummary {
if b == nil {
return nil
}
result := &BuildSummary{
Link: b.Summary.Label,
Status: b.Summary.Status,
PendingTime: b.Summary.PendingTime,
ExecutionTime: b.Summary.ExecutionTime,
Text: b.Summary.Text,
Blame: b.Blame,
}
if b.Trigger != nil {
result.Revision = &b.Trigger.Commit
}
return result
}
// Trigger is the combination of pointing to a single commit, with information
// about where that commit came from (e.g. the repository), and the project
// that triggered it.
type Trigger struct {
Commit
// Source is the trigger source. In buildbot, this would be the "Reason".
// This has no meaning in SwarmBucket and DM yet.
Source string
// Project is the name of the LUCI project responsible for
// triggering the build.
Project string
}
// Property specifies k/v pair representing some
// sort of property, such as buildbot property, quest property, etc.
type Property struct {
Key string
Value string
}
// PropertyGroup is a cluster of similar properties. In buildbot land this would be the "source".
// This is a way to segregate different types of properties such as Quest properties,
// swarming properties, emitted properties, revision properties, etc.
type PropertyGroup struct {
GroupName string
Property []*Property
}
func (p PropertyGroup) Len() int { return len(p.Property) }
func (p PropertyGroup) Swap(i, j int) {
p.Property[i], p.Property[j] = p.Property[j], p.Property[i]
}
func (p PropertyGroup) Less(i, j int) bool { return p.Property[i].Key < p.Property[j].Key }
// Commit represents a single commit to a repository, rendered as part of a blamelist.
type Commit struct {
// Who made the commit?
AuthorName string
// Email of the committer.
AuthorEmail string
// Time of the commit.
CommitTime time.Time
// Full URL of the main source repository.
Repo string
// Branch of the repo.
Branch string
// Requested revision of the commit or base commit.
RequestRevision *Link
// Revision of the commit or base commit.
Revision *Link
// The commit message.
Description string
// Rietveld or Gerrit URL if the commit is a patch.
Changelist *Link
// Browsable URL of the commit.
CommitURL string
// List of changed filenames.
File []string
}
// RevisionHTML returns a single rendered link for the revision, prioritizing
// Revision over RequestRevision.
func (c *Commit) RevisionHTML() template.HTML {
switch {
case c == nil:
return ""
case c.Revision != nil:
return c.Revision.HTML()
case c.RequestRevision != nil:
return c.RequestRevision.HTML()
default:
return ""
}
}
// Title is the first line of the commit message (Description).
func (c *Commit) Title() string {
switch lines := strings.SplitN(c.Description, "\n", 2); len(lines) {
case 0:
return ""
case 1:
return c.Description
default:
return lines[0]
}
}
// DescLines returns the description as a slice, one line per item.
func (c *Commit) DescLines() []string {
return strings.Split(c.Description, "\n")
}
// BuildProgress is a way to show progress. Percent should always be specified.
type BuildProgress struct {
// The total number of entries. Shows up as a tooltip. Leave at 0 to
// disable the tooltip.
total uint64
// The number of entries completed. Shows up as <progress> / <total>.
progress uint64
// A number between 0 to 100 denoting the percentage completed.
percent uint32
}
// ComponentType is the type of build component.
type ComponentType int
const (
// Recipe corresponds to a full recipe run. Dependencies are recipes.
Recipe ComponentType = iota
// StepLegacy is a single step of a recipe.
StepLegacy
// Summary denotes that this does not pretain to any particular step.
Summary
)
// MarshalJSON renders enums into String rather than an int when marshalling.
func (c ComponentType) MarshalJSON() ([]byte, error) {
return json.Marshal(c.String())
}
// LogoBanner is a banner of logos that define the OS and devices that a
// component is associated with.
type LogoBanner struct {
OS []Logo
Device []Logo
}
// Interval is a time interval which has a start, an end and a duration.
type Interval struct {
// Started denotes the start time of this interval.
Started time.Time
// Finished denotest the end time of this interval.
Finished time.Time
// Duration is the length of the interval.
Duration time.Duration
}
// NewInterval returns a new interval struct. If end time is empty (eg. Not completed)
// set end time to empty but set duration to the difference between start and now.
// Getting called with an empty start time and non-empty end time is undefined.
func NewInterval(c context.Context, start, end time.Time) Interval {
i := Interval{Started: start, Finished: end}
if start.IsZero() {
return i
}
if end.IsZero() {
i.Duration = clock.Now(c).Sub(start)
} else {
i.Duration = end.Sub(start)
}
return i
}
// BuildComponent represents a single Step, subsetup, attempt, or recipe.
type BuildComponent struct {
// The parent of this component. For buildbot and swarmbucket builds, this
// refers to the builder. For DM, this refers to whatever triggered the Quest.
ParentLabel *Link `json:",omitempty"`
// The main label for the component.
Label *Link
// Status of the build.
Status model.Status
// Banner is a banner of logos that define the OS and devices this
// component is associated with.
Banner *LogoBanner `json:",omitempty"`
// Bot is the machine or execution instance that this component ran on.
Bot *Link `json:",omitempty"`
// Recipe is a link to the recipe this component is based on.
Recipe *Link `json:",omitempty"`
// Source is a link to the external (buildbot, swarming, dm, etc) data
// source that this component relates to.
Source *Link `json:",omitempty"`
// Links to show adjacent to the main label.
MainLink LinkSet `json:",omitempty"`
// Links to show right below the main label. Top-level slice are rows of
// links, second level shows up as
SubLink []LinkSet `json:",omitempty"`
// Designates the progress of the current component. Set null for no progress.
Progress *BuildProgress `json:",omitempty"`
// Pending is time interval that this build was pending.
PendingTime Interval
// Execution is time interval that this build was executing.
ExecutionTime Interval
// The type of component. This manifests itself as a little label on the
// top left corner of the component.
// This is either "RECIPE" or "STEP". An attempt is considered a recipe.
Type ComponentType
// Arbitrary text to display below links. One line per entry,
// newlines are stripped.
Text []string
// Children of the step. Undefined for other types of build components.
Children []*BuildComponent
// Render a step as collapsed or expanded. Undefined for other types of build
// components.
Collapsed bool
}
var rLineBreak = regexp.MustCompile("<br */?>")
// TextBR returns Text, but also splits each line by <br>
func (bc *BuildComponent) TextBR() []string {
var result []string
for _, t := range bc.Text {
result = append(result, rLineBreak.Split(t, -1)...)
}
return result
}
// Navigation is the top bar of the page, used for navigating out of the page.
type Navigation struct {
// Title of the site, is generally consistent throughout the whole site.
SiteTitle *Link
// Title of the page, usually talks about what's currently here
PageTitle *Link
// List of clickable thing to navigate down the hierarchy.
Breadcrumbs []*Link
// List of (smaller) clickable things to display on the right side.
Right []*Link
}
// LinkSet is an ordered collection of Link objects that will be rendered on the
// same line.
type LinkSet []*Link
// Link denotes a single labeled link.
//
// JSON tags here are for test expectations.
type Link struct {
model.Link
// AriaLabel is a spoken label for the link. Used as aria-label under the anchor tag.
AriaLabel string `json:",omitempty"`
// Img is an icon for the link. Not compatible with label. Rendered as <img>
Img string `json:",omitempty"`
// Alt text for the image, or title text with text link.
Alt string `json:",omitempty"`
// Alias, if true, means that this link is an [alias link].
Alias bool `json:",omitempty"`
}
// NewLink does just about what you'd expect.
func NewLink(label, url, ariaLabel string) *Link {
return &Link{Link: model.Link{Label: label, URL: url}, AriaLabel: ariaLabel}
}
// NewPatchLink is the right way (TM) to generate links to Rietveld/Gerrit CLs.
//
// Returns nil if provided buildset is not Rietveld or Gerrit CL.
func NewPatchLink(cl buildbucketpb.BuildSet) *Link {
switch v := cl.(type) {
case *buildbucketpb.GerritChange:
return NewLink(
fmt.Sprintf("Gerrit CL %d (ps#%d)", v.Change, v.Patchset),
v.URL(),
fmt.Sprintf("gerrit changelist number %d patchset %d", v.Change, v.Patchset))
default:
return nil
}
}
// NewEmptyLink creates a Link struct acting as a pure text label.
func NewEmptyLink(label string) *Link {
return &Link{Link: model.Link{Label: label}}
}
/// HTML methods.
var (
linkifyTemplate = template.Must(
template.New("linkify").
Parse(
`<a{{if .URL}} href="{{.URL}}"{{end}}` +
`{{if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}` +
`{{if .Alt}}{{if not .Img}} title="{{.Alt}}"{{end}}{{end}}>` +
`{{if .Img}}<img src="{{.Img}}"{{if .Alt}} alt="{{.Alt}}"{{end}}>` +
`{{else if .Alias}}[{{.Label}}]` +
`{{else}}{{.Label}}{{end}}` +
`</a>`))
linkifySetTemplate = template.Must(
template.New("linkifySet").
Parse(
`{{ range $i, $link := . }}` +
`{{ if gt $i 0 }} {{ end }}` +
`{{ $link.HTML }}` +
`{{ end }}`))
)
// HTML renders this Link as HTML.
func (l *Link) HTML() template.HTML {
if l == nil {
return ""
}
buf := bytes.Buffer{}
if err := linkifyTemplate.Execute(&buf, l); err != nil {
panic(err)
}
return template.HTML(buf.Bytes())
}
// String renders this Link's Label as a string.
func (l *Link) String() string {
if l == nil {
return ""
}
return l.Label
}
// HTML renders this LinkSet as HTML.
func (l LinkSet) HTML() template.HTML {
if len(l) == 0 {
return ""
}
buf := bytes.Buffer{}
if err := linkifySetTemplate.Execute(&buf, l); err != nil {
panic(err)
}
return template.HTML(buf.Bytes())
}