| // Copyright 2016 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" |
| "fmt" |
| "html/template" |
| "net/url" |
| "strings" |
| |
| "go.chromium.org/luci/milo/common/model" |
| ) |
| |
| // This file contains the structures for defining a Console view. |
| // Console: The main entry point and the overall struct for a console page. |
| // Category: A column in the console representing a builder category that may contain |
| // subcategories (other columns) as well as builders References a commit with a list |
| // of build summaries. |
| // BuilderRef: Used both as an input to request a builder and headers for the console. |
| |
| // This file also contains an interface through which to render the console "tree." |
| // ConsoleElement: Represents a renderable unit of the console. In this case a unit refers |
| // to either a builder (via a BuilderRef) or a category (via a Category). |
| |
| // Console represents a console view. Commit contains a list of commits to be displayed |
| // in this console. Table contains a tree of categories whose leaves are builders. |
| // The builders maintain information regarding the builds for the corresponding commits |
| // in Commit. MaxDepth is a useful piece of metadata for adding in empty rows in the |
| // console's header. |
| type Console struct { |
| Name string |
| |
| // Project is the LUCI project for which this console is defined. |
| Project string |
| |
| // Header is an optional header for the console which contains links, oncall info, |
| // and summaries of other, presumably related consoles. |
| // |
| // This field may be nil, which simply indicates to the renderer to not render a |
| // header. |
| Header *ConsoleHeader |
| |
| // Commit is a list of commits representing the list of commits to the left of the |
| // console. |
| Commit []Commit |
| |
| // Table is a tree of builder categories used to generate the console's main table. |
| // |
| // Leaf nodes must always be of concrete type BuilderRef, interior nodes must |
| // always be of type Category. The root node is a dummy node without a name |
| // to simplify the implementation. |
| Table Category |
| |
| // MaxDepth represents the maximum tree depth of Table. |
| MaxDepth int |
| |
| // FaviconURL is the URL to the favicon for this console. |
| FaviconURL string |
| } |
| |
| // HasCategory returns true if there is at least a single category defined in the console. |
| func (c *Console) HasCategory() bool { |
| if len(c.Table.children) != 1 { |
| return true |
| } |
| root := c.Table.children[0] |
| rootCat, ok := root.(*Category) |
| if !ok { |
| return false // This shouldn't happen. |
| } |
| for _, child := range rootCat.children { |
| if _, ok := child.(*Category); ok { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // BuilderSummaryGroup represents the summary of a console, including its name and the latest |
| // status of each of its builders. |
| type BuilderSummaryGroup struct { |
| // Name is a Link that contains the name of the console as well as a relative URL |
| // to the console's page. |
| Name *Link |
| |
| // Builders contains a list of builders for a given console and some data about |
| // the latest state for each builder. |
| Builders []*model.BuilderSummary |
| } |
| |
| // TreeStatusState indicates the status of a tree. |
| type TreeStatusState string |
| |
| const ( |
| // TreeMaintenance means the tree is under maintenance and is not open. |
| // This has a color of purple. |
| TreeMaintenance TreeStatusState = "maintenance" |
| // TreeThrottled means the tree is backed up, and commits are throttled |
| // to allow the tree to catch up. This has a color of yellow. |
| TreeThrottled = "throttled" |
| // TreeClosed means the tree is broken, and commits other than reverts |
| // or fixes are not accepted at the time. This has a color of red. |
| TreeClosed = "closed" |
| // TreeOpen means the tree is not broken, and commits can happen freely. |
| // This has a color of green. |
| TreeOpen = "open" |
| ) |
| |
| // TreeStatus represents the very top bar of the console, above the header. |
| type TreeStatus struct { |
| // Username is the name of the user who changed the status last. |
| Username string `json:"username"` |
| CanCommitFreely bool `json:"can_commit_freely"` |
| // GeneralState is the general state of the tree, which also indicates |
| // the color of the tree. |
| GeneralState TreeStatusState `json:"general_state"` |
| Key int64 `json:"key"` |
| // Date is when the tree was last updated. The format is YYYY-mm-DD HH:MM:SS.ssssss |
| // and implicitly UTC. eg. "2017-11-10 14:29:21.804080" |
| Date string `json:"date"` |
| // Message is a human readable description of the tree. |
| Message string `json:"message"` |
| // URL is a link to the root status page. This is generated on the Milo side, |
| // not provided by the status app. |
| URL *url.URL |
| } |
| |
| // Oncall represents an oncall role with the current individuals in that role, represented |
| // by their email addresses. |
| // This struct represents the JSON format in which we receive rotation data. |
| type Oncall struct { |
| // Primary is the username of the primary oncall. This is used in lieu of emails. |
| // This is filled in from the remote JSON. |
| Primary string |
| |
| // Secondaries are the usernames of the secondary oncalls. This is used in lieu of emails. |
| // This is filled in from the remote JSON. |
| Secondaries []string |
| |
| // Emails is a list of email addresses for the individuals who are currently in |
| // that role. This is loaded from the sheriffing json. |
| Emails []string |
| } |
| |
| type OncallSummary struct { |
| // Name is the name of the oncall role. This is set in the Milo config. |
| Name string |
| |
| // Oncallers is an HTML template containing the usernames (for Googlers) or email addresses |
| // (for external contributors) of current oncallers. External emails are obfuscated to make |
| // them harder to scrape. If specified in the config, displays "(primary)" and "(secondary)" |
| // after the oncaller names. Displays "<none>" if no-one is oncall. |
| Oncallers template.HTML |
| } |
| |
| // LinkGroup represents a set of links grouped together by some category. |
| type LinkGroup struct { |
| // Name is the name of the category this group of links belongs to. |
| Name *Link |
| |
| // Links is a list of links in this link group. |
| Links []*Link |
| } |
| |
| // ConsoleGroup represents a group of console summaries which may optionally be titled. |
| // Logically, it represents a group of consoles with some shared quality (e.g. tree closers). |
| type ConsoleGroup struct { |
| // Title is the title for this group of consoles and may link to anywhere. |
| Title *Link |
| |
| // Consoles is the list of console summaries contained without this group. |
| Consoles []*BuilderSummaryGroup |
| } |
| |
| // ConsoleHeader represents the header of a console view, containing a set of links, |
| // oncall details, as well as a set of console summaries for other, relevant consoles. |
| type ConsoleHeader struct { |
| // Oncalls is a list of oncall roles and the current people who fill that role |
| // that will be displayed in the header.. |
| Oncalls []*OncallSummary |
| |
| // Links is a list of link groups to be displayed in the header. |
| Links []LinkGroup |
| |
| // ConsoleGroups is a list of groups of console summaries to be displayed in |
| // the header, or nil if there was an error when retrieving consoles. |
| // |
| // A console group without a title will have all of its console summaries |
| // appear "ungrouped" when rendered. |
| ConsoleGroups []ConsoleGroup |
| |
| // ConsoleGroupsErr is the error thrown when retrieving console groups. |
| ConsoleGroupsErr error |
| |
| // TreeStatus indicates the status of the tree if it is not nil. |
| TreeStatus *TreeStatus |
| } |
| |
| // ConsoleElement represents a single renderable console element. |
| type ConsoleElement interface { |
| // Writes HTML into the given byte buffer. |
| // |
| // The two integer parameters represent useful pieces of metadata in |
| // rendering: current depth, and maximum depth. |
| RenderHTML(*bytes.Buffer, int, int) |
| |
| // Returns number of leaf nodes in this console element. |
| NumLeafNodes() int |
| } |
| |
| // Category represents an interior node in a category tree for builders. |
| // |
| // Implements ConsoleElement. |
| type Category struct { |
| Name string |
| |
| // The node's children, which can be any console element. |
| children []ConsoleElement |
| |
| // The node's children in a map to simplify insertion. |
| childrenMap map[string]ConsoleElement |
| |
| // Cached value for the NumLeftNode function. |
| cachedNumLeafNodes int |
| } |
| |
| // NewCategory allocates a new Category struct with no children. |
| func NewCategory(name string) *Category { |
| return &Category{ |
| Name: name, |
| childrenMap: make(map[string]ConsoleElement), |
| children: make([]ConsoleElement, 0), |
| cachedNumLeafNodes: -1, |
| } |
| } |
| |
| // AddBuilder inserts the builder into this Category tree. |
| // |
| // AddBuilder will create new subcategories as a chain of Category structs |
| // as needed until there are no categories remaining. The builder is then |
| // made a child of the deepest such Category. |
| func (c *Category) AddBuilder(categories []string, builder *BuilderRef) { |
| current := c |
| current.cachedNumLeafNodes = -1 |
| for _, category := range categories { |
| if child, ok := current.childrenMap[category]; ok { |
| original := child.(*Category) |
| original.cachedNumLeafNodes = -1 |
| current = original |
| } else { |
| newChild := NewCategory(category) |
| current.childrenMap[category] = ConsoleElement(newChild) |
| current.children = append(current.children, ConsoleElement(newChild)) |
| current = newChild |
| } |
| } |
| current.childrenMap[builder.ID] = ConsoleElement(builder) |
| current.children = append(current.children, ConsoleElement(builder)) |
| } |
| |
| // Children returns a list of child console elements. |
| func (c *Category) Children() []ConsoleElement { |
| // Copy the slice to make it immutable by callers. |
| return append([]ConsoleElement{}, c.children...) |
| } |
| |
| // NumLeafNodes calculates the number of leaf nodes in Category. |
| func (c *Category) NumLeafNodes() int { |
| if c.cachedNumLeafNodes != -1 { |
| return c.cachedNumLeafNodes |
| } |
| |
| leafNodes := 0 |
| for _, child := range c.children { |
| leafNodes += child.NumLeafNodes() |
| } |
| c.cachedNumLeafNodes = leafNodes |
| return c.cachedNumLeafNodes |
| } |
| |
| // BuilderRef is an unambiguous reference to a builder. |
| // |
| // It represents a single column of builds in the console view. |
| // |
| // Implements ConsoleElement. |
| type BuilderRef struct { |
| // ID is the canonical reference to a specific builder. |
| ID string |
| // ShortName is a string of length 1-3 used to label the builder. |
| ShortName string |
| // The most recent build summaries for this builder. |
| Build []*model.BuildSummary |
| // The most recent builder summary for this builder. |
| Builder *model.BuilderSummary |
| } |
| |
| // BuilderName returns the last component of ID (which is the Builder Name). |
| func (br *BuilderRef) BuilderName() string { |
| comp := strings.Split(br.ID, "/") |
| return comp[len(comp)-1] |
| } |
| |
| // Convenience function for writing to bytes.Buffer: in our case, the |
| // writes into the buffer should _never_ fail. It is a catastrophic error |
| // if it does. |
| func must(_ int, err error) { |
| if err != nil { |
| panic(err) |
| } |
| } |
| |
| // State machine states for rendering builds. |
| const ( |
| empty = iota |
| top |
| middle |
| bottom |
| cell |
| ) |
| |
| // RenderHTML renders a BuilderRef as HTML with its builds in a column. |
| // If maxDepth is negative, render the HTML as flat rather than nested. |
| func (br BuilderRef) RenderHTML(buffer *bytes.Buffer, depth int, maxDepth int) { |
| // If render the HTML as flat rather than nested, we don't need to recurse at all and should just |
| // return after rendering the BuilderSummary. |
| if maxDepth < 0 { |
| if br.Builder != nil && br.Builder.LastFinishedBuildID != "" { |
| must(fmt.Fprintf(buffer, `<a class="console-builder-status" href="%s" title="%s">`, |
| template.HTMLEscapeString(br.Builder.LastFinishedBuildIDLink()), |
| template.HTMLEscapeString(br.Builder.BuilderID), |
| )) |
| must(fmt.Fprintf(buffer, `<div class="console-list-builder status-%s critical-%s"></div>`, |
| template.HTMLEscapeString(br.Builder.LastFinishedStatus.String()), |
| template.HTMLEscapeString(br.Builder.LastFinishedCritical.String()), |
| )) |
| } else { |
| must(fmt.Fprintf(buffer, `<a class="console-builder-status" href="/%s" title="%s">`, |
| template.HTMLEscapeString(br.ID), |
| template.HTMLEscapeString(br.ID), |
| )) |
| must(buffer.WriteString(`<div class="console-list-builder"></div>`)) |
| } |
| must(buffer.WriteString(`</a>`)) |
| return |
| } |
| |
| must(buffer.WriteString(`<div class="console-builder-column">`)) |
| // Add spaces if we haven't hit maximum depth to keep the grid consistent. |
| for i := 0; i < (maxDepth - depth); i++ { |
| must(buffer.WriteString(`<div class="console-space"></div>`)) |
| } |
| must(buffer.WriteString(`<div>`)) |
| var extraStatus string |
| if br.Builder != nil { |
| extraStatus += fmt.Sprintf("console-%s", br.Builder.LastFinishedStatus) |
| } |
| must(fmt.Fprintf( |
| buffer, `<span class="%s"><a class="console-builder-item" href="%s" title="%s">%s</a></span>`, |
| template.HTMLEscapeString(extraStatus), |
| template.HTMLEscapeString(br.Builder.SelfLink()), |
| template.HTMLEscapeString(br.BuilderName()), |
| template.HTMLEscapeString(br.ShortName))) |
| must(buffer.WriteString(`</div>`)) |
| |
| must(buffer.WriteString(`<div class="console-build-column">`)) |
| |
| status := "None" |
| link := "#" |
| critical := "UNSET" |
| |
| // Below is a state machine for rendering a single builder's column. |
| // In essence, the state machine takes 3 inputs: the current state, and |
| // the if the next 2 builds exist. It uses this information to choose the |
| // next state. |
| // |
| // Each iteration, the state machine writes out the state's corresponding element, |
| // either a lone cell, the top of a long cell, the bottom of a long cell, the |
| // middle of a long cell, or an empty space. |
| // |
| // The ultimate goal of this state machine is to visually extend a single |
| // build down to the next known build for this builder by commit. |
| |
| // Initialize state machine state. |
| // |
| // Could equivalently be implemented using a "start" state, but |
| // that requires a no-render special case which would make the |
| // state machine less clean. |
| var state int |
| switch { |
| case len(br.Build) == 1: |
| switch { |
| case br.Build[0] != nil: |
| state = cell |
| case br.Build[0] == nil: |
| state = empty |
| } |
| case len(br.Build) > 1: |
| switch { |
| case br.Build[0] != nil && br.Build[1] != nil: |
| state = cell |
| case br.Build[0] != nil && br.Build[1] == nil: |
| state = top |
| case br.Build[0] == nil: |
| state = empty |
| } |
| default: |
| // This is probably a console preview. |
| } |
| // Execute state machine for determining cell type. |
| for i, build := range br.Build { |
| nextBuild := false |
| if i < len(br.Build)-1 { |
| nextBuild = br.Build[i+1] != nil |
| } |
| nextNextBuild := false |
| if i < len(br.Build)-2 { |
| nextNextBuild = br.Build[i+2] != nil |
| } |
| |
| console := "" |
| var nextState int |
| switch state { |
| case empty: |
| console = "empty-cell" |
| critical = "UNSET" |
| switch { |
| case nextBuild && nextNextBuild: |
| nextState = cell |
| case nextBuild && !nextNextBuild: |
| nextState = top |
| case !nextBuild: |
| nextState = empty |
| } |
| case top: |
| console = "cell-top" |
| status = build.Summary.Status.String() |
| link = build.SelfLink() |
| critical = build.Critical.String() |
| switch { |
| case nextNextBuild: |
| nextState = bottom |
| case !nextNextBuild: |
| nextState = middle |
| } |
| case middle: |
| console = "cell-middle" |
| switch { |
| case nextNextBuild: |
| nextState = bottom |
| case !nextNextBuild: |
| nextState = middle |
| } |
| case bottom: |
| console = "cell-bottom" |
| switch { |
| case nextNextBuild: |
| nextState = cell |
| case !nextNextBuild: |
| nextState = top |
| } |
| case cell: |
| console = "cell" |
| status = build.Summary.Status.String() |
| link = build.SelfLink() |
| critical = build.Critical.String() |
| switch { |
| case nextNextBuild: |
| nextState = cell |
| case !nextNextBuild: |
| nextState = top |
| } |
| default: |
| panic("Unrecognized state") |
| } |
| // Write current state's information. |
| class := fmt.Sprintf("console-%s status-%s critical-%s", console, status, critical) |
| must(fmt.Fprintf(buffer, |
| `<div class="console-cell-container"><a class="%s" href="%s" title="%s">`+ |
| `<span class="console-cell-text">%s</span></a><div class="console-cell-spacer"></div></div>`, |
| class, link, |
| template.HTMLEscapeString(br.BuilderName()), |
| br.ShortName)) |
| |
| // Update state. |
| state = nextState |
| } |
| must(buffer.WriteString(`</div></div>`)) |
| } |
| |
| // NumLeafNodes always returns 1 for BuilderDef since it is a leaf node. |
| func (br BuilderRef) NumLeafNodes() int { |
| return 1 |
| } |
| |
| // RenderHTML renders the Category struct and its children as HTML into a buffer. |
| // If maxDepth is negative, skip the labels to render the HTML as flat rather than nested. |
| func (c Category) RenderHTML(buffer *bytes.Buffer, depth int, maxDepth int) { |
| // Check to see if this category is a leaf. |
| // A leaf category has no other categories as it's children. |
| isLeafCategory := true |
| for _, child := range c.children { |
| if _, ok := child.(*Category); ok { |
| isLeafCategory = false |
| break |
| } |
| } |
| |
| if maxDepth > 0 { |
| must(fmt.Fprintf(buffer, `<div class="console-column" style="flex: %d">`, c.NumLeafNodes())) |
| must(fmt.Fprintf(buffer, `<div class="console-top-item">%s</div>`, template.HTMLEscapeString(c.Name))) |
| if isLeafCategory { |
| must(fmt.Fprintf(buffer, `<div class="console-top-row console-leaf-category">`)) |
| } else { |
| must(fmt.Fprintf(buffer, `<div class="console-top-row">`)) |
| } |
| } |
| |
| for _, child := range c.children { |
| child.RenderHTML(buffer, depth+1, maxDepth) |
| } |
| |
| if maxDepth > 0 { |
| must(buffer.WriteString(`</div></div>`)) |
| } |
| } |