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  }