go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/build/legacy/build_page/timeline_tab.tsx (about) 1 // Copyright 2020 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 import { 16 axisBottom, 17 axisLeft, 18 axisTop, 19 BaseType, 20 scaleLinear, 21 scaleTime, 22 select as d3Select, 23 Selection, 24 timeMillisecond, 25 } from 'd3'; 26 import { css, html, render } from 'lit'; 27 import { customElement } from 'lit/decorators.js'; 28 import { DateTime } from 'luxon'; 29 import { autorun, makeObservable, observable } from 'mobx'; 30 31 import '@/generic_libs/components/dot_spinner'; 32 import { RecoverableErrorBoundary } from '@/common/components/error_handling'; 33 import { 34 HideTooltipEventDetail, 35 ShowTooltipEventDetail, 36 } from '@/common/components/tooltip'; 37 import { BUILD_STATUS_CLASS_MAP } from '@/common/constants/legacy'; 38 import { PREDEFINED_TIME_INTERVALS } from '@/common/constants/time'; 39 import { consumeStore, StoreInstance } from '@/common/store'; 40 import { StepExt } from '@/common/store/build_state'; 41 import { commonStyles } from '@/common/styles/stylesheets'; 42 import { 43 displayDuration, 44 NUMERIC_TIME_FORMAT, 45 } from '@/common/tools/time_utils'; 46 import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext'; 47 import { useTabId } from '@/generic_libs/components/routed_tabs'; 48 import { 49 errorHandler, 50 forwardWithoutMsg, 51 reportError, 52 reportRenderError, 53 } from '@/generic_libs/tools/error_handler'; 54 import { enumerate } from '@/generic_libs/tools/iter_utils'; 55 import { consumer } from '@/generic_libs/tools/lit_context'; 56 import { roundDown } from '@/generic_libs/tools/utils'; 57 58 const MARGIN = 10; 59 const TOP_AXIS_HEIGHT = 35; 60 const BOTTOM_AXIS_HEIGHT = 25; 61 const BORDER_SIZE = 1; 62 const HALF_BORDER_SIZE = BORDER_SIZE / 2; 63 64 const ROW_HEIGHT = 30; 65 const STEP_HEIGHT = 24; 66 const STEP_MARGIN = (ROW_HEIGHT - STEP_HEIGHT) / 2 - HALF_BORDER_SIZE; 67 const STEP_EXTRA_WIDTH = 2; 68 69 const TEXT_HEIGHT = 10; 70 const STEP_TEXT_OFFSET = ROW_HEIGHT / 2 + TEXT_HEIGHT / 2; 71 const TEXT_MARGIN = 10; 72 73 const SIDE_PANEL_WIDTH = 400; 74 const MIN_GRAPH_WIDTH = 500 + SIDE_PANEL_WIDTH; 75 const SIDE_PANEL_RECT_WIDTH = 76 SIDE_PANEL_WIDTH - STEP_MARGIN * 2 - BORDER_SIZE * 2; 77 const STEP_IDENT = 15; 78 79 const LIST_ITEM_WIDTH = SIDE_PANEL_RECT_WIDTH - TEXT_MARGIN * 2; 80 const LIST_ITEM_HEIGHT = 16; 81 const LIST_ITEM_X_OFFSET = STEP_MARGIN + TEXT_MARGIN + BORDER_SIZE; 82 const LIST_ITEM_Y_OFFSET = STEP_MARGIN + (STEP_HEIGHT - LIST_ITEM_HEIGHT) / 2; 83 84 const V_GRID_LINE_MAX_GAP = 80; 85 86 @customElement('milo-timeline-tab') 87 @errorHandler(forwardWithoutMsg) 88 @consumer 89 export class TimelineTabElement extends MobxExtLitElement { 90 @observable.ref 91 @consumeStore() 92 store!: StoreInstance; 93 94 @observable.ref private totalWidth!: number; 95 @observable.ref private bodyWidth!: number; 96 97 @observable.ref private headerEle!: HTMLDivElement; 98 @observable.ref private footerEle!: HTMLDivElement; 99 @observable.ref private sidePanelEle!: HTMLDivElement; 100 @observable.ref private bodyEle!: HTMLDivElement; 101 102 // Properties shared between render methods. 103 private bodyHeight!: number; 104 private scaleTime!: d3.ScaleTime<number, number, never>; 105 private scaleStep!: d3.ScaleLinear<number, number, never>; 106 private timeInterval!: d3.TimeInterval; 107 private readonly nowTimestamp = Date.now(); 108 private readonly now = DateTime.fromMillis(this.nowTimestamp); 109 private relativeTimeText!: Selection< 110 SVGTextElement, 111 unknown, 112 null, 113 undefined 114 >; 115 116 constructor() { 117 super(); 118 makeObservable(this); 119 } 120 121 connectedCallback() { 122 super.connectedCallback(); 123 124 const syncWidth = () => { 125 this.totalWidth = Math.max( 126 window.innerWidth - 2 * MARGIN, 127 MIN_GRAPH_WIDTH, 128 ); 129 this.bodyWidth = this.totalWidth - SIDE_PANEL_WIDTH; 130 }; 131 window.addEventListener('resize', syncWidth); 132 this.addDisposer(() => window.removeEventListener('resize', syncWidth)); 133 syncWidth(); 134 135 this.addDisposer(autorun(() => this.renderTimeline())); 136 } 137 138 protected render = reportRenderError(this, () => { 139 if (!this.store.buildPage.build) { 140 return html`<div id="load">Loading <milo-dot-spinner></milo-load-spinner></div>`; 141 } 142 143 if (this.store.buildPage.build.steps.length === 0) { 144 return html`<div id="no-steps">No steps were run.</div>`; 145 } 146 147 return html`<div id="timeline"> 148 ${this.sidePanelEle}${this.headerEle}${this.bodyEle}${this.footerEle} 149 </div>`; 150 }); 151 152 private renderTimeline = reportError(this, () => { 153 const build = this.store.buildPage.build; 154 if (!build || !build.startTime || build.steps.length === 0) { 155 return; 156 } 157 158 const startTime = build.startTime.toMillis(); 159 const endTime = build.endTime?.toMillis() || this.nowTimestamp; 160 161 this.bodyHeight = build.steps.length * ROW_HEIGHT - BORDER_SIZE; 162 const padding = 163 Math.ceil(((endTime - startTime) * STEP_EXTRA_WIDTH) / this.bodyWidth) / 164 2; 165 166 // Calc attributes shared among components. 167 this.scaleTime = scaleTime() 168 // Add a bit of padding to ensure everything renders in the viewport. 169 .domain([startTime - padding, endTime + padding]) 170 // Ensure the right border is rendered within the viewport, while the left 171 // border overlaps with the right border of the side-panel. 172 .range([-HALF_BORDER_SIZE, this.bodyWidth - HALF_BORDER_SIZE]); 173 this.scaleStep = scaleLinear() 174 .domain([0, build.steps.length]) 175 // Ensure the top and bottom borders are not rendered. 176 .range([-HALF_BORDER_SIZE, this.bodyHeight + HALF_BORDER_SIZE]); 177 178 const maxInterval = 179 (endTime - startTime + 2 * padding) / 180 (this.bodyWidth / V_GRID_LINE_MAX_GAP); 181 182 this.timeInterval = timeMillisecond.every( 183 roundDown(maxInterval, PREDEFINED_TIME_INTERVALS), 184 )!; 185 186 // Render each component. 187 this.renderHeader(); 188 this.renderFooter(); 189 this.renderSidePanel(); 190 this.renderBody(); 191 }); 192 193 private renderHeader() { 194 const build = this.store.buildPage.build!; 195 196 this.headerEle = document.createElement('div'); 197 const svg = d3Select(this.headerEle) 198 .attr('id', 'header') 199 .append('svg') 200 .attr('viewport', `0 0 ${this.totalWidth} ${TOP_AXIS_HEIGHT}`); 201 202 svg 203 .append('text') 204 .attr('x', TEXT_MARGIN) 205 .attr('y', TOP_AXIS_HEIGHT - TEXT_MARGIN / 2) 206 .attr('font-weight', '500') 207 .text( 208 'Build Start Time: ' + build.startTime!.toFormat(NUMERIC_TIME_FORMAT), 209 ); 210 211 const headerRootGroup = svg 212 .append('g') 213 .attr( 214 'transform', 215 `translate(${SIDE_PANEL_WIDTH}, ${TOP_AXIS_HEIGHT - HALF_BORDER_SIZE})`, 216 ); 217 const topAxis = axisTop(this.scaleTime).ticks(this.timeInterval); 218 headerRootGroup.call(topAxis); 219 220 this.relativeTimeText = headerRootGroup 221 .append('text') 222 .style('opacity', 0) 223 .attr('id', 'relative-time') 224 .attr('fill', 'red') 225 .attr('y', -TEXT_HEIGHT - TEXT_MARGIN) 226 .attr('text-anchor', 'end'); 227 228 // Top border for the side panel. 229 headerRootGroup 230 .append('line') 231 .attr('x1', -SIDE_PANEL_WIDTH) 232 .attr('stroke', 'var(--default-text-color)'); 233 } 234 235 private renderFooter() { 236 const build = this.store.buildPage.build!; 237 238 this.footerEle = document.createElement('div'); 239 const svg = d3Select(this.footerEle) 240 .attr('id', 'footer') 241 .append('svg') 242 .attr('viewport', `0 0 ${this.totalWidth} ${BOTTOM_AXIS_HEIGHT}`); 243 244 if (build.endTime) { 245 svg 246 .append('text') 247 .attr('x', TEXT_MARGIN) 248 .attr('y', TEXT_HEIGHT + TEXT_MARGIN / 2) 249 .attr('font-weight', '500') 250 .text('Build End Time: ' + build.endTime.toFormat(NUMERIC_TIME_FORMAT)); 251 } 252 253 const footerRootGroup = svg 254 .append('g') 255 .attr('transform', `translate(${SIDE_PANEL_WIDTH}, ${HALF_BORDER_SIZE})`); 256 const bottomAxis = axisBottom(this.scaleTime).ticks(this.timeInterval); 257 footerRootGroup.call(bottomAxis); 258 259 // Bottom border for the side panel. 260 footerRootGroup 261 .append('line') 262 .attr('x1', -SIDE_PANEL_WIDTH) 263 .attr('stroke', 'var(--default-text-color)'); 264 } 265 266 private renderSidePanel() { 267 const build = this.store.buildPage.build!; 268 269 this.sidePanelEle = document.createElement('div'); 270 const svg = d3Select(this.sidePanelEle) 271 .style('width', SIDE_PANEL_WIDTH + 'px') 272 .style('height', this.bodyHeight + 'px') 273 .attr('id', 'side-panel') 274 .append('svg') 275 .attr('viewport', `0 0 ${SIDE_PANEL_WIDTH} ${this.bodyHeight}`); 276 277 // Grid lines 278 const horizontalGridLines = axisLeft(this.scaleStep) 279 .ticks(build.steps.length) 280 .tickFormat(() => '') 281 .tickSize(-SIDE_PANEL_WIDTH) 282 .tickFormat(() => ''); 283 svg.append('g').attr('class', 'grid').call(horizontalGridLines); 284 285 for (const [i, step] of enumerate(build.steps)) { 286 const stepGroup = svg 287 .append('g') 288 .attr('class', BUILD_STATUS_CLASS_MAP[step.status]) 289 .attr('transform', `translate(0, ${i * ROW_HEIGHT})`); 290 291 const rect = stepGroup 292 .append('rect') 293 .attr('x', STEP_MARGIN + BORDER_SIZE) 294 .attr('y', STEP_MARGIN) 295 .attr('width', SIDE_PANEL_RECT_WIDTH) 296 .attr('height', STEP_HEIGHT); 297 this.installStepInteractionHandlers(rect, step); 298 299 const listItem = stepGroup 300 .append('foreignObject') 301 .attr('class', 'not-intractable') 302 .attr('x', LIST_ITEM_X_OFFSET + step.depth * STEP_IDENT) 303 .attr('y', LIST_ITEM_Y_OFFSET) 304 .attr('height', STEP_HEIGHT - LIST_ITEM_Y_OFFSET) 305 .attr('width', LIST_ITEM_WIDTH); 306 listItem.append('xhtml:span').text(step.listNumber + ' '); 307 const stepText = listItem.append('xhtml:span').text(step.selfName); 308 309 if (step.logs[0]?.viewUrl) { 310 stepText.attr('class', 'hyperlink'); 311 } 312 } 313 314 // Left border. 315 svg 316 .append('line') 317 .attr('x1', HALF_BORDER_SIZE) 318 .attr('x2', HALF_BORDER_SIZE) 319 .attr('y2', this.bodyHeight) 320 .attr('stroke', 'var(--default-text-color)'); 321 // Right border. 322 svg 323 .append('line') 324 .attr('x1', SIDE_PANEL_WIDTH - HALF_BORDER_SIZE) 325 .attr('x2', SIDE_PANEL_WIDTH - HALF_BORDER_SIZE) 326 .attr('y2', this.bodyHeight) 327 .attr('stroke', 'var(--default-text-color)'); 328 } 329 330 private renderBody() { 331 const build = this.store.buildPage.build!; 332 333 this.bodyEle = document.createElement('div'); 334 const svg = d3Select(this.bodyEle) 335 .attr('id', 'body') 336 .style('width', this.bodyWidth + 'px') 337 .style('height', this.bodyHeight + 'px') 338 .append('svg') 339 .attr('viewport', `0 0 ${this.bodyWidth} ${this.bodyHeight}`); 340 341 // Grid lines 342 const verticalGridLines = axisTop(this.scaleTime) 343 .ticks(this.timeInterval) 344 .tickSize(-this.bodyHeight) 345 .tickFormat(() => ''); 346 svg.append('g').attr('class', 'grid').call(verticalGridLines); 347 const horizontalGridLines = axisLeft(this.scaleStep) 348 .ticks(build.steps.length) 349 .tickFormat(() => '') 350 .tickSize(-this.bodyWidth) 351 .tickFormat(() => ''); 352 svg.append('g').attr('class', 'grid').call(horizontalGridLines); 353 354 for (const [i, step] of enumerate(build.steps)) { 355 const start = this.scaleTime( 356 step.startTime?.toMillis() || this.nowTimestamp, 357 ); 358 const end = this.scaleTime(step.endTime?.toMillis() || this.nowTimestamp); 359 360 const stepGroup = svg 361 .append('g') 362 .attr('class', BUILD_STATUS_CLASS_MAP[step.status]) 363 .attr('transform', `translate(${start}, ${i * ROW_HEIGHT})`); 364 365 // Add extra width so tiny steps are visible. 366 const width = end - start + STEP_EXTRA_WIDTH; 367 368 stepGroup 369 .append('rect') 370 .attr('x', -STEP_EXTRA_WIDTH / 2) 371 .attr('y', STEP_MARGIN) 372 .attr('width', width) 373 .attr('height', STEP_HEIGHT); 374 375 const isWide = width > this.bodyWidth * 0.33; 376 const nearEnd = end > this.bodyWidth * 0.66; 377 378 const stepText = stepGroup 379 .append('text') 380 .attr('text-anchor', isWide || !nearEnd ? 'start' : 'end') 381 .attr( 382 'x', 383 isWide ? TEXT_MARGIN : nearEnd ? -TEXT_MARGIN : width + TEXT_MARGIN, 384 ) 385 .attr('y', STEP_TEXT_OFFSET) 386 .text(step.listNumber + ' ' + step.selfName); 387 388 // Wail until the next event cycle so stepText is rendered when we call 389 // this.getBBox(); 390 window.setTimeout(() => { 391 // Rebind this so we can access it in the function below. 392 const timelineTab = this; // eslint-disable-line @typescript-eslint/no-this-alias 393 394 stepText.each(function () { 395 // This is the standard d3 API. 396 // eslint-disable-next-line no-invalid-this 397 const textBBox = this.getBBox(); 398 const x1 = Math.min(textBBox.x, -STEP_EXTRA_WIDTH / 2); 399 const x2 = Math.max(textBBox.x + textBBox.width, STEP_MARGIN + width); 400 401 // This makes the step text easier to interact with. 402 const eventTargetRect = stepGroup 403 .append('rect') 404 .attr('x', x1) 405 .attr('y', STEP_MARGIN) 406 .attr('width', x2 - x1) 407 .attr('height', STEP_HEIGHT) 408 .attr('class', 'invisible'); 409 410 timelineTab.installStepInteractionHandlers(eventTargetRect, step); 411 }); 412 }, 10); 413 } 414 415 const yRuler = svg 416 .append('line') 417 .style('opacity', 0) 418 .attr('stroke', 'red') 419 .attr('pointer-events', 'none') 420 .attr('y1', 0) 421 .attr('y2', this.bodyHeight); 422 423 let svgBox: DOMRect | null = null; 424 svg.on('mouseover', () => { 425 this.relativeTimeText.style('opacity', 1); 426 yRuler.style('opacity', 1); 427 }); 428 svg.on('mouseout', () => { 429 this.relativeTimeText.style('opacity', 0); 430 yRuler.style('opacity', 0); 431 }); 432 svg.on('mousemove', (e: MouseEvent) => { 433 if (svgBox === null) { 434 svgBox = svg.node()!.getBoundingClientRect(); 435 } 436 const x = e.pageX - svgBox.x; 437 438 yRuler.attr('x1', x); 439 yRuler.attr('x2', x); 440 441 const time = DateTime.fromJSDate(this.scaleTime.invert(x)); 442 const duration = time.diff(build.startTime!); 443 this.relativeTimeText.attr('x', x); 444 this.relativeTimeText.text( 445 displayDuration(duration) + ' since build start', 446 ); 447 }); 448 449 // Right border. 450 svg 451 .append('line') 452 .attr('x1', this.bodyWidth - HALF_BORDER_SIZE) 453 .attr('x2', this.bodyWidth - HALF_BORDER_SIZE) 454 .attr('y2', this.bodyHeight) 455 .attr('stroke', 'var(--default-text-color)'); 456 } 457 458 /** 459 * Installs handlers for interacting with a step object. 460 */ 461 private installStepInteractionHandlers<T extends BaseType>( 462 ele: Selection<T, unknown, null, undefined>, 463 step: StepExt, 464 ) { 465 const logUrl = step.logs[0]?.viewUrl; 466 if (logUrl) { 467 ele 468 .attr('class', ele.attr('class') + ' clickable') 469 .on('click', (e: MouseEvent) => { 470 e.stopPropagation(); 471 window.open(logUrl, '_blank'); 472 }); 473 } 474 475 ele 476 .on('mouseover', (e: MouseEvent) => { 477 const tooltip = document.createElement('div'); 478 render( 479 html` 480 <table> 481 <tr> 482 <td colspan="2">${ 483 logUrl 484 ? 'Click to open associated log.' 485 : html`<b>No associated log.</b>` 486 }</td> 487 </tr> 488 <tr> 489 <td>Started:</td> 490 <td> 491 ${(step.startTime || this.now).toFormat(NUMERIC_TIME_FORMAT)} 492 (after ${displayDuration( 493 (step.startTime || this.now).diff( 494 this.store.buildPage.build!.startTime!, 495 ), 496 )}) 497 </td> 498 </tr> 499 <tr> 500 <td>Ended:</td> 501 <td>${ 502 step.endTime 503 ? step.endTime.toFormat(NUMERIC_TIME_FORMAT) + 504 ` (after ${displayDuration( 505 step.endTime.diff( 506 this.store.buildPage.build!.startTime!, 507 ), 508 )})` 509 : 'N/A' 510 }</td> 511 </tr> 512 <tr> 513 <td>Duration:</td> 514 <td>${displayDuration(step.duration)}</td> 515 </tr> 516 </div> 517 `, 518 tooltip, 519 ); 520 521 window.dispatchEvent( 522 new CustomEvent<ShowTooltipEventDetail>('show-tooltip', { 523 detail: { 524 tooltip, 525 targetRect: (e.target as HTMLElement).getBoundingClientRect(), 526 gapSize: 5, 527 }, 528 }), 529 ); 530 }) 531 .on('mouseout', () => { 532 window.dispatchEvent( 533 new CustomEvent<HideTooltipEventDetail>('hide-tooltip', { 534 detail: { delay: 0 }, 535 }), 536 ); 537 }); 538 } 539 540 static styles = [ 541 commonStyles, 542 css` 543 :host { 544 display: block; 545 margin: ${MARGIN}px; 546 } 547 548 #load { 549 color: var(--active-text-color); 550 } 551 552 #timeline { 553 display: grid; 554 grid-template-rows: ${TOP_AXIS_HEIGHT}px 1fr ${BOTTOM_AXIS_HEIGHT}px; 555 grid-template-columns: ${SIDE_PANEL_WIDTH}px 1fr; 556 grid-template-areas: 557 'header header' 558 'side-panel body' 559 'footer footer'; 560 margin-top: ${-MARGIN}px; 561 } 562 563 #header { 564 grid-area: header; 565 position: sticky; 566 top: 0; 567 background: white; 568 z-index: 2; 569 } 570 571 #footer { 572 grid-area: footer; 573 position: sticky; 574 bottom: 0; 575 background: white; 576 z-index: 2; 577 } 578 579 #side-panel { 580 grid-area: side-panel; 581 z-index: 1; 582 font-weight: 500; 583 } 584 585 #body { 586 grid-area: body; 587 } 588 589 #body path.domain { 590 stroke: none; 591 } 592 593 svg { 594 width: 100%; 595 height: 100%; 596 } 597 598 text { 599 fill: var(--default-text-color); 600 } 601 602 #relative-time { 603 fill: red; 604 } 605 606 .grid line { 607 stroke: var(--divider-color); 608 } 609 610 .clickable { 611 cursor: pointer; 612 } 613 .not-intractable { 614 pointer-events: none; 615 } 616 .hyperlink { 617 text-decoration: underline; 618 } 619 620 .scheduled > rect { 621 stroke: var(--scheduled-color); 622 fill: var(--scheduled-bg-color); 623 } 624 .started > rect { 625 stroke: var(--started-color); 626 fill: var(--started-bg-color); 627 } 628 .success > rect { 629 stroke: var(--success-color); 630 fill: var(--success-bg-color); 631 } 632 .failure > rect { 633 stroke: var(--failure-color); 634 fill: var(--failure-bg-color); 635 } 636 .infra-failure > rect { 637 stroke: var(--critical-failure-color); 638 fill: var(--critical-failure-bg-color); 639 } 640 .canceled > rect { 641 stroke: var(--canceled-color); 642 fill: var(--canceled-bg-color); 643 } 644 645 .invisible { 646 opacity: 0; 647 } 648 `, 649 ]; 650 } 651 652 declare global { 653 // eslint-disable-next-line @typescript-eslint/no-namespace 654 namespace JSX { 655 interface IntrinsicElements { 656 'milo-timeline-tab': Record<string, never>; 657 } 658 } 659 } 660 661 export function TimelineTab() { 662 return <milo-timeline-tab />; 663 } 664 665 export function Component() { 666 useTabId('timeline'); 667 668 return ( 669 // See the documentation for `<LoginPage />` for why we handle error this 670 // way. 671 <RecoverableErrorBoundary key="timeline"> 672 <TimelineTab /> 673 </RecoverableErrorBoundary> 674 ); 675 }