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 }