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 }