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 }