go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/build/legacy/build_page/steps_tab/step_cluster.ts (about)

     1  // Copyright 2022 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 { styleMap } from 'lit/directives/style-map.js';
    19  import { DateTime } from 'luxon';
    20  import { action, computed, makeObservable, observable, reaction } from 'mobx';
    21  
    22  import './step_entry';
    23  import checkCircleStacked from '@/common/assets/svgs/check_circle_stacked_24dp.svg';
    24  import {
    25    HideTooltipEventDetail,
    26    ShowTooltipEventDetail,
    27  } from '@/common/components/tooltip';
    28  import { consumeStore, StoreInstance } from '@/common/store';
    29  import { StepExt } from '@/common/store/build_state';
    30  import { commonStyles } from '@/common/styles/stylesheets';
    31  import {
    32    displayCompactDuration,
    33    displayDuration,
    34    NUMERIC_TIME_FORMAT,
    35  } from '@/common/tools/time_utils';
    36  import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext';
    37  import { consumer } from '@/generic_libs/tools/lit_context';
    38  
    39  import { BuildPageStepEntryElement } from './step_entry';
    40  
    41  @customElement('milo-bp-step-cluster')
    42  @consumer
    43  export class BuildPageStepClusterElement extends MobxExtLitElement {
    44    @observable.ref @consumeStore() store!: StoreInstance;
    45    @observable.ref steps!: readonly StepExt[];
    46  
    47    @observable.ref private expanded = false;
    48  
    49    @computed private get shouldElide() {
    50      return (
    51        this.steps.length > 1 &&
    52        !this.steps[0].isCritical &&
    53        this.store.userConfig.build.steps.elideSucceededSteps &&
    54        !this.expanded
    55      );
    56    }
    57  
    58    @computed private get startTime() {
    59      return this.steps.reduce((earliest: DateTime | null, step) => {
    60        if (!earliest) {
    61          return step.startTime;
    62        }
    63        if (!step.startTime) {
    64          return earliest;
    65        }
    66        return step.startTime < earliest ? step.startTime : earliest;
    67      }, null);
    68    }
    69  
    70    @computed private get endTime() {
    71      return this.steps.reduce((latest: DateTime | null, step) => {
    72        if (!latest) {
    73          return step.endTime;
    74        }
    75        if (!step.endTime) {
    76          return latest;
    77        }
    78        return step.endTime > latest ? step.endTime : latest;
    79      }, null);
    80    }
    81  
    82    @computed get duration() {
    83      if (!this.startTime || !this.endTime) {
    84        return null;
    85      }
    86  
    87      return this.endTime.diff(this.startTime);
    88    }
    89  
    90    @action private setExpanded(expand: boolean) {
    91      this.expanded = expand;
    92    }
    93  
    94    constructor() {
    95      super();
    96      makeObservable(this);
    97    }
    98  
    99    private expandSteps = false;
   100    toggleAllSteps(expand: boolean) {
   101      this.expandSteps = expand;
   102      this.setExpanded(expand);
   103      this.shadowRoot!.querySelectorAll<BuildPageStepEntryElement>(
   104        'milo-bp-step-entry',
   105      ).forEach((e) => e.toggleAllSteps(expand));
   106    }
   107  
   108    connectedCallback() {
   109      super.connectedCallback();
   110  
   111      this.addDisposer(
   112        reaction(
   113          () => this.store.userConfig.build.steps.elideSucceededSteps,
   114          (elideSucceededSteps) => this.setExpanded(!elideSucceededSteps),
   115        ),
   116      );
   117    }
   118  
   119    protected render() {
   120      return html`${this.renderElidedSteps()}${this.renderSteps()}`;
   121    }
   122  
   123    private renderElidedSteps() {
   124      if (!this.shouldElide) {
   125        return;
   126      }
   127  
   128      const firstStepLabel = this.steps[0].index + 1;
   129      const lastStepLabel = this.steps[this.steps.length - 1].index + 1;
   130  
   131      return html`
   132        <div id="elided-steps" @click=${() => this.setExpanded(true)}>
   133          <mwc-icon>more_horiz</mwc-icon>
   134          <svg width="24" height="24">
   135            <image href=${checkCircleStacked} width="24" height="24" />
   136          </svg>
   137          ${this.renderDuration()}
   138          <div id="elided-steps-description">
   139            Step ${firstStepLabel} ~ ${lastStepLabel} succeeded.
   140          </div>
   141        </div>
   142      `;
   143    }
   144  
   145    private renderDuration() {
   146      const [compactDuration, compactDurationUnits] = displayCompactDuration(
   147        this.duration,
   148      );
   149  
   150      return html`
   151        <div
   152          class="duration ${compactDurationUnits}"
   153          @mouseover=${(e: MouseEvent) => {
   154            const tooltip = document.createElement('div');
   155            render(this.renderDurationTooltip(), tooltip);
   156  
   157            window.dispatchEvent(
   158              new CustomEvent<ShowTooltipEventDetail>('show-tooltip', {
   159                detail: {
   160                  tooltip,
   161                  targetRect: (e.target as HTMLElement).getBoundingClientRect(),
   162                  gapSize: 5,
   163                },
   164              }),
   165            );
   166          }}
   167          @mouseout=${() => {
   168            window.dispatchEvent(
   169              new CustomEvent<HideTooltipEventDetail>('hide-tooltip', {
   170                detail: { delay: 50 },
   171              }),
   172            );
   173          }}
   174        >
   175          ${compactDuration}
   176        </div>
   177      `;
   178    }
   179  
   180    private renderDurationTooltip() {
   181      return html`
   182        <table>
   183          <tr>
   184            <td>Started:</td>
   185            <td>${
   186              this.startTime
   187                ? this.startTime.toFormat(NUMERIC_TIME_FORMAT)
   188                : 'N/A'
   189            }</td>
   190          </tr>
   191          <tr>
   192            <td>Ended:</td>
   193            <td>${
   194              this.endTime ? this.endTime.toFormat(NUMERIC_TIME_FORMAT) : 'N/A'
   195            }</td>
   196          </tr>
   197          <tr>
   198            <td>Duration:</td>
   199            <td>${this.duration ? displayDuration(this.duration) : 'N/A'}</td>
   200          </tr>
   201        </div>
   202      `;
   203    }
   204  
   205    private renderedSteps = false;
   206    private renderSteps() {
   207      if (!this.renderedSteps && this.shouldElide) {
   208        return;
   209      }
   210      // Once rendered to DOM, always render to DOM since we have done the hard
   211      // work.
   212      this.renderedSteps = true;
   213  
   214      return html`
   215        <div style=${styleMap({ display: this.shouldElide ? 'none' : 'block' })}>
   216          ${this.steps.map(
   217            (step) =>
   218              html`<milo-bp-step-entry
   219                .step=${step}
   220                .expanded=${this.expandSteps}
   221              ></milo-bp-step-entry>`,
   222          )}
   223        </div>
   224      `;
   225    }
   226  
   227    static styles = [
   228      commonStyles,
   229      css`
   230        :host {
   231          display: block;
   232        }
   233  
   234        #elided-steps {
   235          display: grid;
   236          grid-template-columns: auto auto auto 1fr;
   237          grid-gap: 5px;
   238          height: 24px;
   239          line-height: 24px;
   240          cursor: pointer;
   241        }
   242  
   243        .duration {
   244          margin-top: 3px;
   245          margin-bottom: 5px;
   246        }
   247  
   248        #elided-steps-description {
   249          padding-left: 4px;
   250          font-weight: bold;
   251          font-style: italic;
   252        }
   253  
   254        milo-bp-step-entry {
   255          margin-bottom: 2px;
   256        }
   257      `,
   258    ];
   259  }