go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/frontend/ui/console.go (about)

     1  // Copyright 2016 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package ui
    16  
    17  import (
    18  	"bytes"
    19  	"fmt"
    20  	"html/template"
    21  	"net/url"
    22  	"strings"
    23  
    24  	"go.chromium.org/luci/milo/internal/model"
    25  )
    26  
    27  // This file contains the structures for defining a Console view.
    28  // Console: The main entry point and the overall struct for a console page.
    29  // Category: A column in the console representing a builder category that may contain
    30  //   subcategories (other columns) as well as builders  References a commit with a list
    31  //   of build summaries.
    32  // BuilderRef: Used both as an input to request a builder and headers for the console.
    33  
    34  // This file also contains an interface through which to render the console "tree."
    35  // ConsoleElement: Represents a renderable unit of the console. In this case a unit refers
    36  //   to either a builder (via a BuilderRef) or a category (via a Category).
    37  
    38  // Console represents a console view.  Commit contains a list of commits to be displayed
    39  // in this console. Table contains a tree of categories whose leaves are builders.
    40  // The builders maintain information regarding the builds for the corresponding commits
    41  // in Commit. MaxDepth is a useful piece of metadata for adding in empty rows in the
    42  // console's header.
    43  type Console struct {
    44  	Name string
    45  
    46  	// Project is the LUCI project for which this console is defined.
    47  	Project string
    48  
    49  	// Header is an optional header for the console which contains links, oncall info,
    50  	// and summaries of other, presumably related consoles.
    51  	//
    52  	// This field may be nil, which simply indicates to the renderer to not render a
    53  	// header.
    54  	Header *ConsoleHeader
    55  
    56  	// Commit is a list of commits representing the list of commits to the left of the
    57  	// console.
    58  	Commit []Commit
    59  
    60  	// Table is a tree of builder categories used to generate the console's main table.
    61  	//
    62  	// Leaf nodes must always be of concrete type BuilderRef, interior nodes must
    63  	// always be of type Category. The root node is a dummy node without a name
    64  	// to simplify the implementation.
    65  	Table Category
    66  
    67  	// MaxDepth represents the maximum tree depth of Table.
    68  	MaxDepth int
    69  
    70  	// FaviconURL is the URL to the favicon for this console.
    71  	FaviconURL string
    72  }
    73  
    74  // HasCategory returns true if there is at least a single category defined in the console.
    75  func (c *Console) HasCategory() bool {
    76  	if len(c.Table.children) != 1 {
    77  		return true
    78  	}
    79  	root := c.Table.children[0]
    80  	rootCat, ok := root.(*Category)
    81  	if !ok {
    82  		return false // This shouldn't happen.
    83  	}
    84  	for _, child := range rootCat.children {
    85  		if _, ok := child.(*Category); ok {
    86  			return true
    87  		}
    88  	}
    89  	return false
    90  }
    91  
    92  // BuilderSummaryGroup represents the summary of a console, including its name and the latest
    93  // status of each of its builders.
    94  type BuilderSummaryGroup struct {
    95  	// Name is a Link that contains the name of the console as well as a relative URL
    96  	// to the console's page.
    97  	Name *Link
    98  
    99  	// Builders contains a list of builders for a given console and some data about
   100  	// the latest state for each builder.
   101  	Builders []*model.BuilderSummary
   102  }
   103  
   104  // TreeStatusState indicates the status of a tree.
   105  type TreeStatusState string
   106  
   107  const (
   108  	// TreeMaintenance means the tree is under maintenance and is not open.
   109  	// This has a color of purple.
   110  	TreeMaintenance TreeStatusState = "maintenance"
   111  	// TreeThrottled means the tree is backed up, and commits are throttled
   112  	// to allow the tree to catch up.  This has a color of yellow.
   113  	TreeThrottled = "throttled"
   114  	// TreeClosed means the tree is broken, and commits other than reverts
   115  	// or fixes are not accepted at the time.  This has a color of red.
   116  	TreeClosed = "closed"
   117  	// TreeOpen means the tree is not broken, and commits can happen freely.
   118  	// This has a color of green.
   119  	TreeOpen = "open"
   120  )
   121  
   122  // TreeStatus represents the very top bar of the console, above the header.
   123  type TreeStatus struct {
   124  	// Username is the name of the user who changed the status last.
   125  	Username        string `json:"username"`
   126  	CanCommitFreely bool   `json:"can_commit_freely"`
   127  	// GeneralState is the general state of the tree, which also indicates
   128  	// the color of the tree.
   129  	GeneralState TreeStatusState `json:"general_state"`
   130  	Key          int64           `json:"key"`
   131  	// Date is when the tree was last updated.  The format is YYYY-mm-DD HH:MM:SS.ssssss
   132  	// and implicitly UTC.  eg. "2017-11-10 14:29:21.804080"
   133  	Date string `json:"date"`
   134  	// Message is a human readable description of the tree.
   135  	Message string `json:"message"`
   136  	// URL is a link to the root status page.  This is generated on the Milo side,
   137  	// not provided by the status app.
   138  	URL *url.URL
   139  }
   140  
   141  // Oncall represents an oncall role with the current individuals in that role, represented
   142  // by their email addresses.
   143  // This struct represents the JSON format in which we receive rotation data.
   144  type Oncall struct {
   145  	// Primary is the username of the primary oncall.  This is used in lieu of emails.
   146  	// This is filled in from the remote JSON.
   147  	Primary string
   148  
   149  	// Secondaries are the usernames of the secondary oncalls.  This is used in lieu of emails.
   150  	// This is filled in from the remote JSON.
   151  	Secondaries []string
   152  
   153  	// Emails is a list of email addresses for the individuals who are currently in
   154  	// that role.  This is loaded from the sheriffing json.
   155  	Emails []string
   156  }
   157  
   158  type OncallSummary struct {
   159  	// Name is the name of the oncall role.  This is set in the Milo config.
   160  	Name string
   161  
   162  	// Oncallers is an HTML template containing the usernames (for Googlers) or email addresses
   163  	// (for external contributors) of current oncallers. External emails are obfuscated to make
   164  	// them harder to scrape. If specified in the config, displays "(primary)" and "(secondary)"
   165  	// after the oncaller names. Displays "<none>" if no-one is oncall.
   166  	Oncallers template.HTML
   167  }
   168  
   169  // LinkGroup represents a set of links grouped together by some category.
   170  type LinkGroup struct {
   171  	// Name is the name of the category this group of links belongs to.
   172  	Name *Link
   173  
   174  	// Links is a list of links in this link group.
   175  	Links []*Link
   176  }
   177  
   178  // ConsoleGroup represents a group of console summaries which may optionally be titled.
   179  // Logically, it represents a group of consoles with some shared quality (e.g. tree closers).
   180  type ConsoleGroup struct {
   181  	// Title is the title for this group of consoles and may link to anywhere.
   182  	Title *Link
   183  
   184  	// Consoles is the list of console summaries contained without this group.
   185  	Consoles []*BuilderSummaryGroup
   186  }
   187  
   188  // ConsoleHeader represents the header of a console view, containing a set of links,
   189  // oncall details, as well as a set of console summaries for other, relevant consoles.
   190  type ConsoleHeader struct {
   191  	// Oncalls is a list of oncall roles and the current people who fill that role
   192  	// that will be displayed in the header..
   193  	Oncalls []*OncallSummary
   194  
   195  	// Links is a list of link groups to be displayed in the header.
   196  	Links []LinkGroup
   197  
   198  	// ConsoleGroups is a list of groups of console summaries to be displayed in
   199  	// the header, or nil if there was an error when retrieving consoles.
   200  	//
   201  	// A console group without a title will have all of its console summaries
   202  	// appear "ungrouped" when rendered.
   203  	ConsoleGroups []ConsoleGroup
   204  
   205  	// ConsoleGroupsErr is the error thrown when retrieving console groups.
   206  	ConsoleGroupsErr error
   207  
   208  	// TreeStatus indicates the status of the tree if it is not nil.
   209  	TreeStatus *TreeStatus
   210  }
   211  
   212  // ConsoleElement represents a single renderable console element.
   213  type ConsoleElement interface {
   214  	// Writes HTML into the given byte buffer.
   215  	//
   216  	// The two integer parameters represent useful pieces of metadata in
   217  	// rendering: current depth, and maximum depth.
   218  	RenderHTML(*bytes.Buffer, int, int)
   219  
   220  	// Returns number of leaf nodes in this console element.
   221  	NumLeafNodes() int
   222  }
   223  
   224  // Category represents an interior node in a category tree for builders.
   225  //
   226  // Implements ConsoleElement.
   227  type Category struct {
   228  	Name string
   229  
   230  	// The node's children, which can be any console element.
   231  	children []ConsoleElement
   232  
   233  	// The node's children in a map to simplify insertion.
   234  	childrenMap map[string]ConsoleElement
   235  
   236  	// Cached value for the NumLeftNode function.
   237  	cachedNumLeafNodes int
   238  }
   239  
   240  // NewCategory allocates a new Category struct with no children.
   241  func NewCategory(name string) *Category {
   242  	return &Category{
   243  		Name:               name,
   244  		childrenMap:        make(map[string]ConsoleElement),
   245  		children:           make([]ConsoleElement, 0),
   246  		cachedNumLeafNodes: -1,
   247  	}
   248  }
   249  
   250  // AddBuilder inserts the builder into this Category tree.
   251  //
   252  // AddBuilder will create new subcategories as a chain of Category structs
   253  // as needed until there are no categories remaining. The builder is then
   254  // made a child of the deepest such Category.
   255  func (c *Category) AddBuilder(categories []string, builder *BuilderRef) {
   256  	current := c
   257  	current.cachedNumLeafNodes = -1
   258  	for _, category := range categories {
   259  		if child, ok := current.childrenMap[category]; ok {
   260  			original := child.(*Category)
   261  			original.cachedNumLeafNodes = -1
   262  			current = original
   263  		} else {
   264  			newChild := NewCategory(category)
   265  			current.childrenMap[category] = ConsoleElement(newChild)
   266  			current.children = append(current.children, ConsoleElement(newChild))
   267  			current = newChild
   268  		}
   269  	}
   270  	current.childrenMap[builder.ID] = ConsoleElement(builder)
   271  	current.children = append(current.children, ConsoleElement(builder))
   272  }
   273  
   274  // Children returns a list of child console elements.
   275  func (c *Category) Children() []ConsoleElement {
   276  	// Copy the slice to make it immutable by callers.
   277  	return append([]ConsoleElement{}, c.children...)
   278  }
   279  
   280  // NumLeafNodes calculates the number of leaf nodes in Category.
   281  func (c *Category) NumLeafNodes() int {
   282  	if c.cachedNumLeafNodes != -1 {
   283  		return c.cachedNumLeafNodes
   284  	}
   285  
   286  	leafNodes := 0
   287  	for _, child := range c.children {
   288  		leafNodes += child.NumLeafNodes()
   289  	}
   290  	c.cachedNumLeafNodes = leafNodes
   291  	return c.cachedNumLeafNodes
   292  }
   293  
   294  // BuilderRef is an unambiguous reference to a builder.
   295  //
   296  // It represents a single column of builds in the console view.
   297  //
   298  // Implements ConsoleElement.
   299  type BuilderRef struct {
   300  	// ID is the canonical reference to a specific builder.
   301  	ID string
   302  	// ShortName is a string of length 1-3 used to label the builder.
   303  	ShortName string
   304  	// The most recent build summaries for this builder.
   305  	Build []*model.BuildSummary
   306  	// The most recent builder summary for this builder.
   307  	Builder *model.BuilderSummary
   308  }
   309  
   310  // BuilderName returns the last component of ID (which is the Builder Name).
   311  func (br *BuilderRef) BuilderName() string {
   312  	comp := strings.Split(br.ID, "/")
   313  	return comp[len(comp)-1]
   314  }
   315  
   316  // Convenience function for writing to bytes.Buffer: in our case, the
   317  // writes into the buffer should _never_ fail. It is a catastrophic error
   318  // if it does.
   319  func must(_ int, err error) {
   320  	if err != nil {
   321  		panic(err)
   322  	}
   323  }
   324  
   325  // State machine states for rendering builds.
   326  const (
   327  	empty = iota
   328  	top
   329  	middle
   330  	bottom
   331  	cell
   332  )
   333  
   334  // RenderHTML renders a BuilderRef as HTML with its builds in a column.
   335  // If maxDepth is negative, render the HTML as flat rather than nested.
   336  func (br BuilderRef) RenderHTML(buffer *bytes.Buffer, depth int, maxDepth int) {
   337  	// If render the HTML as flat rather than nested, we don't need to recurse at all and should just
   338  	// return after rendering the BuilderSummary.
   339  	if maxDepth < 0 {
   340  		if br.Builder != nil && br.Builder.LastFinishedBuildID != "" {
   341  			must(fmt.Fprintf(buffer, `<a class="console-builder-status" href="%s" title="%s">`,
   342  				template.HTMLEscapeString(br.Builder.LastFinishedBuildIDLink()),
   343  				template.HTMLEscapeString(br.Builder.BuilderID),
   344  			))
   345  			must(fmt.Fprintf(buffer, `<div class="console-list-builder status-%s critical-%s"></div>`,
   346  				template.HTMLEscapeString(br.Builder.LastFinishedStatus.String()),
   347  				template.HTMLEscapeString(br.Builder.LastFinishedCritical.String()),
   348  			))
   349  		} else {
   350  			must(fmt.Fprintf(buffer, `<a class="console-builder-status" href="/%s" title="%s">`,
   351  				template.HTMLEscapeString(br.ID),
   352  				template.HTMLEscapeString(br.ID),
   353  			))
   354  			must(buffer.WriteString(`<div class="console-list-builder"></div>`))
   355  		}
   356  		must(buffer.WriteString(`</a>`))
   357  		return
   358  	}
   359  
   360  	must(buffer.WriteString(`<div class="console-builder-column">`))
   361  	// Add spaces if we haven't hit maximum depth to keep the grid consistent.
   362  	for i := 0; i < (maxDepth - depth); i++ {
   363  		must(buffer.WriteString(`<div class="console-space"></div>`))
   364  	}
   365  	must(buffer.WriteString(`<div>`))
   366  	var extraStatus string
   367  	if br.Builder != nil {
   368  		extraStatus += fmt.Sprintf("console-%s", br.Builder.LastFinishedStatus)
   369  	}
   370  	must(fmt.Fprintf(
   371  		buffer, `<span class="%s"><a class="console-builder-item" href="%s" title="%s">%s</a></span>`,
   372  		template.HTMLEscapeString(extraStatus),
   373  		template.HTMLEscapeString(br.Builder.SelfLink()),
   374  		template.HTMLEscapeString(br.BuilderName()),
   375  		template.HTMLEscapeString(br.ShortName)))
   376  	must(buffer.WriteString(`</div>`))
   377  
   378  	must(buffer.WriteString(`<div class="console-build-column">`))
   379  
   380  	status := "None"
   381  	link := "#"
   382  	critical := "UNSET"
   383  
   384  	// Below is a state machine for rendering a single builder's column.
   385  	// In essence, the state machine takes 3 inputs: the current state, and
   386  	// the if the next 2 builds exist. It uses this information to choose the
   387  	// next state.
   388  	//
   389  	// Each iteration, the state machine writes out the state's corresponding element,
   390  	// either a lone cell, the top of a long cell, the bottom of a long cell, the
   391  	// middle of a long cell, or an empty space.
   392  	//
   393  	// The ultimate goal of this state machine is to visually extend a single
   394  	// build down to the next known build for this builder by commit.
   395  
   396  	// Initialize state machine state.
   397  	//
   398  	// Could equivalently be implemented using a "start" state, but
   399  	// that requires a no-render special case which would make the
   400  	// state machine less clean.
   401  	var state int
   402  	switch {
   403  	case len(br.Build) == 1:
   404  		switch {
   405  		case br.Build[0] != nil:
   406  			state = cell
   407  		case br.Build[0] == nil:
   408  			state = empty
   409  		}
   410  	case len(br.Build) > 1:
   411  		switch {
   412  		case br.Build[0] != nil && br.Build[1] != nil:
   413  			state = cell
   414  		case br.Build[0] != nil && br.Build[1] == nil:
   415  			state = top
   416  		case br.Build[0] == nil:
   417  			state = empty
   418  		}
   419  	default:
   420  		// This is probably a console preview.
   421  	}
   422  	// Execute state machine for determining cell type.
   423  	for i, build := range br.Build {
   424  		nextBuild := false
   425  		if i < len(br.Build)-1 {
   426  			nextBuild = br.Build[i+1] != nil
   427  		}
   428  		nextNextBuild := false
   429  		if i < len(br.Build)-2 {
   430  			nextNextBuild = br.Build[i+2] != nil
   431  		}
   432  
   433  		console := ""
   434  		var nextState int
   435  		switch state {
   436  		case empty:
   437  			console = "empty-cell"
   438  			critical = "UNSET"
   439  			switch {
   440  			case nextBuild && nextNextBuild:
   441  				nextState = cell
   442  			case nextBuild && !nextNextBuild:
   443  				nextState = top
   444  			case !nextBuild:
   445  				nextState = empty
   446  			}
   447  		case top:
   448  			console = "cell-top"
   449  			status = build.Summary.Status.String()
   450  			link = build.SelfLink()
   451  			critical = build.Critical.String()
   452  			switch {
   453  			case nextNextBuild:
   454  				nextState = bottom
   455  			case !nextNextBuild:
   456  				nextState = middle
   457  			}
   458  		case middle:
   459  			console = "cell-middle"
   460  			switch {
   461  			case nextNextBuild:
   462  				nextState = bottom
   463  			case !nextNextBuild:
   464  				nextState = middle
   465  			}
   466  		case bottom:
   467  			console = "cell-bottom"
   468  			switch {
   469  			case nextNextBuild:
   470  				nextState = cell
   471  			case !nextNextBuild:
   472  				nextState = top
   473  			}
   474  		case cell:
   475  			console = "cell"
   476  			status = build.Summary.Status.String()
   477  			link = build.SelfLink()
   478  			critical = build.Critical.String()
   479  			switch {
   480  			case nextNextBuild:
   481  				nextState = cell
   482  			case !nextNextBuild:
   483  				nextState = top
   484  			}
   485  		default:
   486  			panic("Unrecognized state")
   487  		}
   488  		// Write current state's information.
   489  		class := fmt.Sprintf("console-%s status-%s critical-%s", console, status, critical)
   490  		must(fmt.Fprintf(buffer,
   491  			`<div class="console-cell-container"><a class="%s" href="%s" title="%s">`+
   492  				`<span class="console-cell-text">%s</span></a><div class="console-cell-spacer"></div></div>`,
   493  			class, link,
   494  			template.HTMLEscapeString(br.BuilderName()),
   495  			br.ShortName))
   496  
   497  		// Update state.
   498  		state = nextState
   499  	}
   500  	must(buffer.WriteString(`</div></div>`))
   501  }
   502  
   503  // NumLeafNodes always returns 1 for BuilderDef since it is a leaf node.
   504  func (br BuilderRef) NumLeafNodes() int {
   505  	return 1
   506  }
   507  
   508  // RenderHTML renders the Category struct and its children as HTML into a buffer.
   509  // If maxDepth is negative, skip the labels to render the HTML as flat rather than nested.
   510  func (c Category) RenderHTML(buffer *bytes.Buffer, depth int, maxDepth int) {
   511  	// Check to see if this category is a leaf.
   512  	// A leaf category has no other categories as it's children.
   513  	isLeafCategory := true
   514  	for _, child := range c.children {
   515  		if _, ok := child.(*Category); ok {
   516  			isLeafCategory = false
   517  			break
   518  		}
   519  	}
   520  
   521  	if maxDepth > 0 {
   522  		must(fmt.Fprintf(buffer, `<div class="console-column" style="flex: %d">`, c.NumLeafNodes()))
   523  		must(fmt.Fprintf(buffer, `<div class="console-top-item">%s</div>`, template.HTMLEscapeString(c.Name)))
   524  		if isLeafCategory {
   525  			must(fmt.Fprintf(buffer, `<div class="console-top-row console-leaf-category">`))
   526  		} else {
   527  			must(fmt.Fprintf(buffer, `<div class="console-top-row">`))
   528  		}
   529  	}
   530  
   531  	for _, child := range c.children {
   532  		child.RenderHTML(buffer, depth+1, maxDepth)
   533  	}
   534  
   535  	if maxDepth > 0 {
   536  		must(buffer.WriteString(`</div></div>`))
   537  	}
   538  }