go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/build/legacy/build_page/steps_tab/step_entry.ts (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 '@material/mwc-icon';
    16  import { css, html, render } from 'lit';
    17  import { customElement } from 'lit/decorators.js';
    18  import { classMap } from 'lit/directives/class-map.js';
    19  import { styleMap } from 'lit/directives/style-map.js';
    20  import { computed, makeObservable, observable, reaction } from 'mobx';
    21  
    22  import '@/generic_libs/components/copy_to_clipboard';
    23  import '@/generic_libs/components/expandable_entry';
    24  import '@/common/components/buildbucket_log_link';
    25  import '@/generic_libs/components/pin_toggle';
    26  import './step_cluster';
    27  
    28  import {
    29    HideTooltipEventDetail,
    30    ShowTooltipEventDetail,
    31  } from '@/common/components/tooltip';
    32  import {
    33    BUILD_STATUS_CLASS_MAP,
    34    BUILD_STATUS_DISPLAY_MAP,
    35    BUILD_STATUS_ICON_MAP,
    36  } from '@/common/constants/legacy';
    37  import { BuildbucketStatus } from '@/common/services/buildbucket';
    38  import { consumeStore, StoreInstance } from '@/common/store';
    39  import { StepExt } from '@/common/store/build_state';
    40  import { ExpandStepOption } from '@/common/store/user_config/build_config';
    41  import { colorClasses, commonStyles } from '@/common/styles/stylesheets';
    42  import {
    43    displayCompactDuration,
    44    displayDuration,
    45    NUMERIC_TIME_FORMAT,
    46  } from '@/common/tools/time_utils';
    47  import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext';
    48  import {
    49    lazyRendering,
    50    RenderPlaceHolder,
    51  } from '@/generic_libs/tools/observer_element';
    52  
    53  import { BuildPageStepClusterElement } from './step_cluster';
    54  
    55  /**
    56   * Renders a step.
    57   */
    58  @customElement('milo-bp-step-entry')
    59  @lazyRendering
    60  export class BuildPageStepEntryElement
    61    extends MobxExtLitElement
    62    implements RenderPlaceHolder
    63  {
    64    @observable.ref
    65    @consumeStore()
    66    store!: StoreInstance;
    67  
    68    @observable.ref step!: StepExt;
    69  
    70    @observable.ref private _expanded = false;
    71  
    72    @computed get expanded() {
    73      return this._expanded;
    74    }
    75    set expanded(newVal) {
    76      this._expanded = newVal;
    77      // Always render the content once it was expanded so the descendants' states
    78      // don't get reset after the node is collapsed.
    79      this.shouldRenderContent = this.shouldRenderContent || newVal;
    80    }
    81  
    82    @observable.ref private shouldRenderContent = false;
    83  
    84    toggleAllSteps(expand: boolean) {
    85      this.expanded = expand;
    86      this.shadowRoot!.querySelectorAll<BuildPageStepClusterElement>(
    87        'milo-bp-step-cluster',
    88      ).forEach((e) => e.toggleAllSteps(expand));
    89    }
    90  
    91    constructor() {
    92      super();
    93      makeObservable(this);
    94    }
    95  
    96    private renderContent() {
    97      if (!this.shouldRenderContent) {
    98        return html``;
    99      }
   100      // We have to cloneNode below because otherwise lit 'uses' the HTML elements
   101      // and if we try to render them a second time we get an empty box.
   102      return html`
   103        <div
   104          id="summary"
   105          class="${BUILD_STATUS_CLASS_MAP[this.step.status]}-bg"
   106          style=${styleMap({
   107            display: this.step.summary ? '' : 'none',
   108          })}
   109        >
   110          ${this.step.summary?.cloneNode(true)}
   111        </div>
   112        <ul
   113          id="log-links"
   114          style=${styleMap({
   115            display: this.step.filteredLogs.length ? '' : 'none',
   116          })}
   117        >
   118          ${this.step.filteredLogs.map(
   119            (log) =>
   120              html`<li>
   121                <milo-buildbucket-log-link
   122                  .log=${log}
   123                ></milo-buildbucket-log-link>
   124              </li>`,
   125          )}
   126        </ul>
   127        ${this.step.tags.length
   128          ? html`<milo-tags-entry .tags=${this.step.tags}></milo-tags-entry>`
   129          : ''}
   130        ${this.step.clusteredChildren.map(
   131          (cluster) =>
   132            html`<milo-bp-step-cluster .steps=${cluster}></milo-bp-step-cluster>`,
   133        ) || ''}
   134      `;
   135    }
   136  
   137    private renderDuration() {
   138      if (!this.step.startTime) {
   139        return html` <span class="duration" title="No duration">N/A</span> `;
   140      }
   141  
   142      const [compactDuration, compactDurationUnits] = displayCompactDuration(
   143        this.step.duration,
   144      );
   145  
   146      return html`
   147        <div
   148          class="duration ${compactDurationUnits}"
   149          @mouseover=${(e: MouseEvent) => {
   150            const tooltip = document.createElement('div');
   151            render(this.renderDurationTooltip(), tooltip);
   152  
   153            window.dispatchEvent(
   154              new CustomEvent<ShowTooltipEventDetail>('show-tooltip', {
   155                detail: {
   156                  tooltip,
   157                  targetRect: (e.target as HTMLElement).getBoundingClientRect(),
   158                  gapSize: 5,
   159                },
   160              }),
   161            );
   162          }}
   163          @mouseout=${() => {
   164            window.dispatchEvent(
   165              new CustomEvent<HideTooltipEventDetail>('hide-tooltip', {
   166                detail: { delay: 50 },
   167              }),
   168            );
   169          }}
   170        >
   171          ${compactDuration}
   172        </div>
   173      `;
   174    }
   175  
   176    private renderDurationTooltip() {
   177      if (!this.step.startTime) {
   178        return html``;
   179      }
   180      return html`
   181        <table>
   182          <tr>
   183            <td>Started:</td>
   184            <td>${this.step.startTime.toFormat(NUMERIC_TIME_FORMAT)}</td>
   185          </tr>
   186          <tr>
   187            <td>Ended:</td>
   188            <td>${
   189              this.step.endTime
   190                ? this.step.endTime.toFormat(NUMERIC_TIME_FORMAT)
   191                : 'N/A'
   192            }</td>
   193          </tr>
   194          <tr>
   195            <td>Duration:</td>
   196            <td>${displayDuration(this.step.duration)}</td>
   197          </tr>
   198        </div>
   199      `;
   200    }
   201  
   202    connectedCallback() {
   203      super.connectedCallback();
   204      this.addDisposer(
   205        reaction(
   206          () => this.store.userConfig.build.steps.expandByDefault,
   207          (opt) => {
   208            switch (opt) {
   209              case ExpandStepOption.All:
   210                this.expanded = true;
   211                break;
   212              case ExpandStepOption.None:
   213                this.expanded = false;
   214                break;
   215              case ExpandStepOption.NonSuccessful:
   216                this.expanded = this.step.status !== BuildbucketStatus.Success;
   217                break;
   218              case ExpandStepOption.WithNonSuccessful:
   219                this.expanded = !this.step.succeededRecursively;
   220                break;
   221            }
   222          },
   223          { fireImmediately: true },
   224        ),
   225      );
   226    }
   227  
   228    firstUpdated() {
   229      if (this.step.isPinned) {
   230        this.expanded = true;
   231  
   232        // Keep the pin setting fresh.
   233        this.step.setIsPinned(this.step.isPinned);
   234      }
   235    }
   236  
   237    renderPlaceHolder() {
   238      return '';
   239    }
   240  
   241    protected render() {
   242      return html`
   243        <milo-expandable-entry
   244          .expanded=${this.expanded}
   245          .onToggle=${(expanded: boolean) => (this.expanded = expanded)}
   246        >
   247          <span id="header" slot="header">
   248            <mwc-icon
   249              id="status-indicator"
   250              class=${BUILD_STATUS_CLASS_MAP[this.step.status]}
   251              title=${BUILD_STATUS_DISPLAY_MAP[this.step.status]}
   252            >
   253              ${BUILD_STATUS_ICON_MAP[this.step.status]}
   254            </mwc-icon>
   255            ${this.renderDuration()}
   256            <div
   257              id="header-text"
   258              class=${classMap({
   259                [`${BUILD_STATUS_CLASS_MAP[this.step.status]}-bg`]:
   260                  this.step.status !== BuildbucketStatus.Success &&
   261                  !(this.expanded && this.step.summary),
   262              })}
   263            >
   264              <b>${this.step.index + 1}. ${this.step.selfName}</b>
   265              <milo-pin-toggle
   266                .pinned=${this.step.isPinned}
   267                title="Pin/unpin the step. The configuration is shared across all builds."
   268                class="hidden-icon"
   269                style=${styleMap({
   270                  visibility: this.step.isPinned ? 'visible' : '',
   271                })}
   272                @click=${(e: Event) => {
   273                  this.step.setIsPinned(!this.step.isPinned);
   274                  e.stopPropagation();
   275                }}
   276              >
   277              </milo-pin-toggle>
   278              <milo-copy-to-clipboard
   279                .textToCopy=${this.step.name}
   280                title="Copy the step name."
   281                class="hidden-icon"
   282                @click=${(e: Event) => e.stopPropagation()}
   283              ></milo-copy-to-clipboard>
   284              <span id="header-markdown"
   285                >${this.expanded ? null : this.step.summary}</span
   286              >
   287              ${!this.expanded && this.step.summary?.title
   288                ? html` <milo-copy-to-clipboard
   289                    .textToCopy=${this.step.summary.title}
   290                    title="Copy the step summary."
   291                    class="hidden-icon"
   292                    @click=${(e: Event) => e.stopPropagation()}
   293                  ></milo-copy-to-clipboard>`
   294                : html``}
   295            </div>
   296          </span>
   297          <div id="content" slot="content">${this.renderContent()}</div>
   298        </milo-expandable-entry>
   299      `;
   300    }
   301  
   302    static styles = [
   303      commonStyles,
   304      colorClasses,
   305      css`
   306        :host {
   307          display: block;
   308          min-height: 24px;
   309        }
   310  
   311        #header {
   312          display: inline-grid;
   313          grid-template-columns: auto auto 1fr;
   314          grid-gap: 5px;
   315          width: 100%;
   316          overflow: hidden;
   317          text-overflow: ellipsis;
   318        }
   319        .hidden-icon {
   320          visibility: hidden;
   321        }
   322        #header:hover .hidden-icon {
   323          visibility: visible;
   324        }
   325        #header.success > b {
   326          color: var(--default-text-color);
   327        }
   328  
   329        #status-indicator {
   330          vertical-align: bottom;
   331        }
   332  
   333        .duration {
   334          margin-top: 3px;
   335          margin-bottom: 5px;
   336        }
   337  
   338        #header-text {
   339          padding-left: 4px;
   340          box-sizing: border-box;
   341          height: 24px;
   342          overflow: hidden;
   343          text-overflow: ellipsis;
   344          display: grid;
   345          grid-template-columns: auto auto auto auto 1fr;
   346        }
   347  
   348        #header-markdown {
   349          overflow: hidden;
   350          text-overflow: ellipsis;
   351        }
   352  
   353        #header-markdown * {
   354          display: inline;
   355        }
   356  
   357        #content {
   358          margin-top: 2px;
   359          overflow: hidden;
   360        }
   361  
   362        #summary {
   363          padding: 5px;
   364          clear: both;
   365          overflow-wrap: break-word;
   366        }
   367  
   368        #summary > p:first-child {
   369          margin-block-start: 0px;
   370        }
   371  
   372        #summary > :last-child {
   373          margin-block-end: 0px;
   374        }
   375  
   376        #summary a {
   377          color: var(--default-text-color);
   378        }
   379  
   380        #log-links {
   381          margin: 3px 0;
   382          padding-inline-start: 28px;
   383          clear: both;
   384          overflow-wrap: break-word;
   385        }
   386  
   387        #log-links > li {
   388          list-style-type: circle;
   389        }
   390      `,
   391    ];
   392  }