blob: ee373d31e2e2ed30aa74303612a9208087f0d993 [file] [log] [blame]
// Copyright 2018 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.
package ui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"html/template"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/golang/protobuf/jsonpb"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/buildbucket/protoutil"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/milo/common"
"go.chromium.org/luci/milo/common/model"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
)
var crosMainRE = regexp.MustCompile(`^cros/parent_buildbucket_id/(\d+)$`)
// Step encapsulates a buildbucketpb.Step, and also allows it to carry
// nesting information.
type Step struct {
*buildbucketpb.Step
Children []*Step `json:"children,omitempty"`
Collapsed bool `json:"collapsed,omitempty"`
Interval common.Interval `json:"interval,omitempty"`
}
// ShortName returns the leaf name of a potentially nested step.
// Eg. With a name of GrandParent|Parent|Child, this returns "Child"
func (s *Step) ShortName() string {
parts := strings.Split(s.Name, "|")
if len(parts) == 0 {
return "ERROR: EMPTY NAME"
}
return parts[len(parts)-1]
}
// Build wraps a buildbucketpb.Build to provide useful templating functions.
// It is used in both BuildPage (in this file) and BuilderPage (builder.go).
type Build struct {
*buildbucketpb.Build
// Now is the current time, used to generate durations that may depend
// on the current time.
Now *timestamppb.Timestamp `json:"now,omitempty"`
}
// CommitLinkHTML returns an HTML link pointing to the output commit, or input commit
// if the output commit is not available.
func (b *Build) CommitLinkHTML() template.HTML {
c := b.GetOutput().GetGitilesCommit()
if c == nil {
c = b.GetInput().GetGitilesCommit()
}
if c == nil {
return ""
}
// Choose a link label.
var label string
switch {
case c.Position != 0:
label = fmt.Sprintf("%s@{#%d}", c.Ref, c.Position)
case c.Id != "":
label = c.Id
case c.Ref != "":
label = c.Ref
default:
return ""
}
return NewLink(label, protoutil.GitilesCommitURL(c), "commit "+label).HTML()
}
// BuildPage represents a build page on Milo.
// The core of the build page is the underlying build proto, but can contain
// extra information depending on the context, for example a blamelist,
// and the user's display preferences.
type BuildPage struct {
// Build is the underlying build proto for the build page.
Build
// Blame is a list of people and commits that likely caused the build result.
// It is usually used as the list of commits between the previous run of the
// build on the same builder, and this run.
Blame []*Commit `json:"blame,omitempty"`
// BuildBugLink is a URL to be used a feedback link for the build. If the
// link could not be generated an empty string will be returned. There will be
// no link, for example, if the project has not set up their build bug template.
BuildBugLink string `json:"build_bug_link,omitempty"`
// BuildbucketHost is the hostname for the buildbucket instance this build came from.
BuildbucketHost string `json:"buildbucket_host,omitempty"`
// Errors contains any non-critical errors encountered while rendering the page.
Errors []error `json:"errors,omitempty"`
// Mode to render the steps.
StepDisplayPref StepDisplayPref `json:"step_display_pref,omitempty"`
// Iff true, show all log links whose name starts with '$'.
ShowDebugLogsPref bool `json:"show_debug_logs_pref,omitempty"`
// timelineData caches the results from Timeline().
timelineData string
// steps caches the result of Steps().
steps []*Step
// BlamelistError holds errors related to the blamelist.
// This determines the behavior of clicking the "blamelist" tab.
BlamelistError error `json:"blamelist_error,omitempty"`
// ForcedBlamelist indicates that the user forced a blamelist load.
ForcedBlamelist bool `json:"forced_blamelist,omitempty"`
// Whether the user is able to perform certain actions on this build
CanCancel bool `json:"can_cancel,omitempty"`
CanRetry bool `json:"can_retry,omitempty"`
}
// RelatedBuildsTable represents a related builds table on Milo.
type RelatedBuildsTable struct {
// Build is the underlying build proto for the build page.
Build `json:"build,omitempty"`
// RelatedBuilds are build summaries with the same buildset.
RelatedBuilds []*Build `json:"related_builds,omitempty"`
}
func NewBuildPage(c context.Context, b *buildbucketpb.Build) *BuildPage {
now := timestamppb.New(clock.Now(c))
return &BuildPage{
Build: Build{Build: b, Now: now},
}
}
// ChangeLinks returns a slice of links to build input gerrit changes.
func (b *Build) ChangeLinks() []*Link {
changes := b.GetInput().GetGerritChanges()
ret := make([]*Link, len(changes))
for i, c := range changes {
ret[i] = NewPatchLink(c)
}
return ret
}
func (b *Build) RecipeLink() *Link {
projectName := b.GetBuilder().GetProject()
cipdPackage := b.GetExe().GetCipdPackage()
recipeName := b.GetInput().GetProperties().GetFields()["recipe"].GetStringValue()
// We don't know location of recipes within the repo and getting that
// information is not trivial, so use code search, which is precise enough.
csHost := "source.chromium.org"
if strings.Contains(cipdPackage, "internal") {
csHost = "source.corp.google.com"
}
// TODO(crbug.com/1149540): remove this conditional once the long-term
// solution for recipe links has been implemented.
if projectName == "flutter" {
csHost = "cs.opensource.google"
}
u := url.URL{
Scheme: "https",
Host: csHost,
Path: "/search/",
RawQuery: url.Values{
"q": []string{fmt.Sprintf(`file:recipes/%s.py`, recipeName)},
}.Encode(),
}
return NewLink(recipeName, u.String(), fmt.Sprintf("recipe %s", recipeName))
}
// BuildbucketLink returns a link to the buildbucket version of the page.
func (bp *BuildPage) BuildbucketLink() *Link {
if bp.BuildbucketHost == "" {
return nil
}
u := url.URL{
Scheme: "https",
Host: bp.BuildbucketHost,
Path: "/rpcexplorer/services/buildbucket.v2.Builds/GetBuild",
RawQuery: url.Values{
"request": []string{fmt.Sprintf(`{"id":"%d"}`, bp.Id)},
}.Encode(),
}
return NewLink(
fmt.Sprintf("%d", bp.Id),
u.String(),
"Buildbucket RPC explorer for build")
}
func (b *Build) BuildSets() []string {
return protoutil.BuildSets(b.Build)
}
func (b *Build) BuildSetLinks() []template.HTML {
buildSets := b.BuildSets()
links := make([]template.HTML, 0, len(buildSets))
for _, buildSet := range buildSets {
result := crosMainRE.FindStringSubmatch(buildSet)
if result == nil {
// Don't know how to link, just return the text.
links = append(links, template.HTML(template.HTMLEscapeString(buildSet)))
} else {
// This linking is for legacy ChromeOS builders to show a link to their
// main builder, it can be removed when these have been transitioned
// to parallel CQ.
buildbucketId := result[1]
builderURL := fmt.Sprintf("https://ci.chromium.org/b/%s", buildbucketId)
ariaLabel := fmt.Sprintf("Main builder %s", buildbucketId)
link := NewLink(buildSet, builderURL, ariaLabel)
links = append(links, link.HTML())
}
}
return links
}
// Steps converts the flat Steps from the underlying Build into a tree.
// The tree is only calculated on the first call, all subsequent calls return cached information.
// TODO(hinoka): Print nicer error messages instead of panicking for invalid build protos.
func (bp *BuildPage) Steps() []*Step {
if bp.steps != nil {
return bp.steps
}
collapseGreen := bp.StepDisplayPref == StepDisplayDefault
// Use a map to store all the known steps, so that children can find their parents.
// This assumes that parents will always be traversed before children,
// which is always true in the build proto.
stepMap := map[string]*Step{}
for _, step := range bp.Build.Steps {
s := &Step{
Step: step,
Collapsed: collapseGreen && step.Status == buildbucketpb.Status_SUCCESS,
Interval: common.ToInterval(step.GetStartTime(), step.GetEndTime(), bp.Now),
}
stepMap[step.Name] = s
switch nameParts := strings.Split(step.Name, "|"); len(nameParts) {
case 0:
panic("Invalid build.proto: Step with missing name.")
case 1:
// Root step.
bp.steps = append(bp.steps, s)
default:
parentName := step.Name[:strings.LastIndex(step.Name, "|")]
parent, ok := stepMap[parentName]
if !ok {
panic("Invalid build.proto: Missing parent.")
}
parent.Children = append(parent.Children, s)
}
}
return bp.steps
}
// HumanStatus returns a human friendly string for the status.
func (b *Build) HumanStatus() string {
switch b.Status {
case buildbucketpb.Status_SCHEDULED:
return "Pending"
case buildbucketpb.Status_STARTED:
return "Running"
case buildbucketpb.Status_SUCCESS:
return "Success"
case buildbucketpb.Status_FAILURE:
return "Failure"
case buildbucketpb.Status_INFRA_FAILURE:
return "Infra Failure"
case buildbucketpb.Status_CANCELED:
return "Canceled"
default:
return "Unknown status"
}
}
// ShouldShowCanaryWarning returns true for failed canary builds.
func (b *Build) ShouldShowCanaryWarning() bool {
return b.Canary && (b.Status == buildbucketpb.Status_FAILURE || b.Status == buildbucketpb.Status_INFRA_FAILURE)
}
type property struct {
// Name is the name of the property relative to a build.
// Note: We call this a "Name" not a "Key", since this was the term used in BuildBot.
Name string `json:"name,omitempty"`
// Value is a JSON string of the value.
Value string `json:"value,omitempty"`
}
// properties returns the values in the proto struct fields as
// a json rendered slice of pairs, sorted by key.
func properties(props *structpb.Struct) []property {
if props == nil {
return nil
}
// Render the fields to JSON.
m := jsonpb.Marshaler{}
buf := bytes.NewBuffer(nil)
if err := m.Marshal(buf, props); err != nil {
panic(err) // This shouldn't happen.
}
d := json.NewDecoder(buf)
jsonProps := map[string]json.RawMessage{}
if err := d.Decode(&jsonProps); err != nil {
panic(err) // This shouldn't happen.
}
// Sort the names.
names := make([]string, 0, len(jsonProps))
for n := range jsonProps {
names = append(names, n)
}
sort.Strings(names)
// Rearrange the fields into a slice.
results := make([]property, len(jsonProps))
for i, n := range names {
buf.Reset()
json.Indent(buf, jsonProps[n], "", " ")
results[i] = property{
Name: n,
Value: buf.String(),
}
}
return results
}
func (bp *BuildPage) InputProperties() []property {
return properties(bp.GetInput().GetProperties())
}
func (bp *BuildPage) OutputProperties() []property {
return properties(bp.GetOutput().GetProperties())
}
// BuilderLink returns a link to the builder in b.
func (b *Build) BuilderLink() *Link {
if b.Builder == nil {
panic("Invalid build")
}
builder := b.Builder
return NewLink(
builder.Builder,
fmt.Sprintf("/p/%s/builders/%s/%s", builder.Project, builder.Bucket, builder.Builder),
fmt.Sprintf("Builder %s in bucket %s", builder.Builder, builder.Bucket))
}
// Link is a self link to the build.
func (b *Build) Link() *Link {
if b.Builder == nil {
panic("invalid build")
}
num := b.Id
// Prefer build number below, but if using buildbucket ID
// a b prefix is needed on the buildbucket ID for it to work.
numStr := fmt.Sprintf("b%d", num)
if b.Number != 0 {
num = int64(b.Number)
numStr = strconv.FormatInt(num, 10)
}
builder := b.Builder
return NewLink(
fmt.Sprintf("%d", num),
fmt.Sprintf("/p/%s/builders/%s/%s/%s", builder.Project, builder.Bucket, builder.Builder, numStr),
fmt.Sprintf("Build %d", num))
}
// Banners returns names of icons to display next to the build number.
// Currently displayed:
// * OS, as determined by swarming dimensions.
// TODO(hinoka): For device builders, display device type, and number of devices.
func (b *Build) Banners() (result []Logo) {
var os, ver string
// A swarming dimension may have multiple values. Eg.
// Linux, Ubuntu, Ubuntu-14.04. We want the most specific one.
// The most specific one always comes last.
for _, dim := range b.GetInfra().GetSwarming().GetBotDimensions() {
if dim.Key != "os" {
continue
}
os = dim.Value
parts := strings.SplitN(os, "-", 2)
if len(parts) == 2 {
os = parts[0]
ver = parts[1]
}
}
var base LogoBase
switch os {
case "Ubuntu":
base = Ubuntu
case "Windows":
base = Windows
case "Mac":
base = OSX
case "Android":
base = Android
default:
return
}
return []Logo{{
LogoBase: base,
Subtitle: ver,
Count: 1,
}}
}
// 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"
)
// Commit represents a single commit to a repository, rendered as part of a blamelist.
type Commit struct {
// Who made the commit?
AuthorName string `json:"author_name,omitempty"`
// Email of the committer.
AuthorEmail string `json:"author_email,omitempty"`
// Time of the commit.
CommitTime time.Time `json:"commit_time,omitempty"`
// Full URL of the main source repository.
Repo string `json:"repo,omitempty"`
// Branch of the repo.
Branch string `json:"branch,omitempty"`
// Requested revision of the commit or base commit.
RequestRevision *Link `json:"request_revision,omitempty"`
// Revision of the commit or base commit.
Revision *Link `json:"revision,omitempty"`
// The commit message.
Description string `json:"description,omitempty"`
// Rietveld or Gerrit URL if the commit is a patch.
Changelist *Link `json:"changelist,omitempty"`
// Browsable URL of the commit.
CommitURL string `json:"commit_url,omitempty"`
// List of changed filenames.
File []string `json:"file,omitempty"`
}
// 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")
}
// Timeline returns a JSON parsable string that can be fed into a viz timeline component.
func (bp *BuildPage) Timeline() string {
// Return the cached version, if it exists already.
if bp.timelineData != "" {
return bp.timelineData
}
// stepData is extra data to deliver with the groups and items (see below) for the
// Javascript vis Timeline component. Note that the step data is encoded in markdown
// in the step.SummaryMarkdown field. We do not show this data on the timeline at this
// time.
type stepData struct {
Label string `json:"label"`
Duration string `json:"duration"`
LogURL string `json:"logUrl"`
StatusClassName string `json:"statusClassName"`
}
// group corresponds to, and matches the shape of, a Group for the Javascript
// vis Timeline component http://visjs.org/docs/timeline/#groups. Data
// rides along as an extra property (unused by vis Timeline itself) used
// in client side rendering. Each Group is rendered as its own row in the
// timeline component on to which Items are rendered. Currently we only render
// one Item per Group, that is one thing per row.
type group struct {
ID string `json:"id"`
Data stepData `json:"data"`
}
// item corresponds to, and matches the shape of, an Item for the Javascript
// vis Timeline component http://visjs.org/docs/timeline/#items. Data
// rides along as an extra property (unused by vis Timeline itself) used
// in client side rendering. Each Item is rendered to a Group which corresponds
// to a row. Currently we only render one Item per Group, that is one thing per
// row.
type item struct {
ID string `json:"id"`
Group string `json:"group"`
Start int64 `json:"start"`
End int64 `json:"end"`
Type string `json:"type"`
ClassName string `json:"className"`
Data stepData `json:"data"`
}
now := bp.Now.AsTime()
groups := make([]group, len(bp.Build.Steps))
items := make([]item, len(bp.Build.Steps))
for i, step := range bp.Build.Steps {
groupID := strconv.Itoa(i)
logURL := ""
if len(step.Logs) > 0 {
logURL = html.EscapeString(step.Logs[0].ViewUrl)
}
statusClassName := fmt.Sprintf("status-%s", step.Status)
data := stepData{
Label: html.EscapeString(step.Name),
Duration: common.Duration(step.StartTime, step.EndTime, bp.Now),
LogURL: logURL,
StatusClassName: statusClassName,
}
groups[i] = group{groupID, data}
start := step.StartTime.AsTime()
end := step.EndTime.AsTime()
if end.IsZero() || end.Before(start) {
end = now
}
items[i] = item{
ID: groupID,
Group: groupID,
Start: milliseconds(start),
End: milliseconds(end),
Type: "range",
ClassName: statusClassName,
Data: data,
}
}
timeline, err := json.Marshal(map[string]interface{}{
"groups": groups,
"items": items,
})
if err != nil {
bp.Errors = append(bp.Errors, err)
return "error"
}
return string(timeline)
}
// milliseconds returns the given time in number of milliseconds elapsed since epoch.
func milliseconds(time time.Time) int64 {
return time.UnixNano() / 1e6
}
/// 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}}{{.Label}}{{end}}` +
`</a>`))
linkifySetTemplate = template.Must(
template.New("linkifySet").
Parse(
`{{ range $i, $link := . }}` +
`{{ if gt $i 0 }} {{ end }}` +
`{{ $link.HTML }}` +
`{{ end }}`))
newBuildPageOptInTemplate = template.Must(
template.New("optIn").
Parse(`
<div id="opt-in-banner">
<div id="opt-in-link">
Switch to
<a
id="new-build-page-link"
{{if .Number}}
href="/ui/p/{{.Builder.Project}}/builders/{{.Builder.Bucket}}/{{.Builder.Builder}}/{{.Number}}"
{{else}}
href="/ui/p/{{.Builder.Project}}/builders/{{.Builder.Bucket}}/{{.Builder.Builder}}/b{{.Id}}"
{{end}}
>the new build page!</a>
</div>
<div id="feedback-bar">
Or <a id="feedback-link" href="/">tell us what's missing</a>.
[<a id="dismiss-feedback-bar" href="/">dismiss</a>]
</div>
</div>`))
)
// 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())
}
// NewBuildPageOptInHTML returns a link to the new build page of the build.
func (b *Build) NewBuildPageOptInHTML() template.HTML {
buf := bytes.Buffer{}
if err := newBuildPageOptInTemplate.Execute(&buf, b); err != nil {
panic(err)
}
return template.HTML(buf.Bytes())
}
// 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:"aria_label,omitempty"`
// Img is an icon for the link. Not compatible with label. Rendered as <img>
Img string `json:"img,omitempty"`
// Alt text for the image, or title text with text link.
Alt string `json:"alt,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 generates a URL to a Gerrit CL.
func NewPatchLink(cl *buildbucketpb.GerritChange) *Link {
return NewLink(
fmt.Sprintf("CL %d (ps#%d)", cl.Change, cl.Patchset),
protoutil.GerritChangeURL(cl),
fmt.Sprintf("gerrit changelist number %d patchset %d", cl.Change, cl.Patchset))
}
// NewEmptyLink creates a Link struct acting as a pure text label.
func NewEmptyLink(label string) *Link {
return &Link{Link: model.Link{Label: label}}
}
// BuildPageData represents a build page on Milo.
// Comparing to BuildPage, it caches a lot of the computed properties so they
// be serialised to JSON.
type BuildPageData struct {
*BuildPage
CommitLinkHTML template.HTML `json:"commit_link_html,omitempty"`
Summary []string `json:"summary,omitempty"`
RecipeLink *Link `json:"recipe_link,omitempty"`
BuildbucketLink *Link `json:"buildbucket_link,omitempty"`
BuildSets []string `json:"build_sets,omitempty"`
BuildSetLinks []template.HTML `json:"build_set_links,omitempty"`
Steps []*Step `json:"steps,omitempty"`
HumanStatus string `json:"human_status,omitempty"`
ShouldShowCanaryWarning bool `json:"should_show_canary_warning,omitempty"`
InputProperties []property `json:"input_properties,omitempty"`
OutputProperties []property `json:"output_properties,omitempty"`
BuilderLink *Link `json:"builder_link,omitempty"`
Link *Link `json:"link,omitempty"`
Banners []Logo `json:"banners,omitempty"`
Timeline string `json:"timeline,omitempty"`
}