go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/test_verdict/legacy/invocation_page/invocation_details_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 '@material/mwc-icon'; 16 import { css, html } 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 { autorun, computed, makeObservable, observable } from 'mobx'; 21 22 import '@/common/components/timeline'; 23 import { RecoverableErrorBoundary } from '@/common/components/error_handling'; 24 import { TimelineBlock } from '@/common/components/timeline'; 25 import { Invocation } from '@/common/services/resultdb'; 26 import { consumeStore, StoreInstance } from '@/common/store'; 27 import { 28 consumeInvocationState, 29 InvocationStateInstance, 30 } from '@/common/store/invocation_state'; 31 import { commonStyles } from '@/common/styles/stylesheets'; 32 import { logging } from '@/common/tools/logging'; 33 import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext'; 34 import { useTabId } from '@/generic_libs/components/routed_tabs'; 35 import { consumer } from '@/generic_libs/tools/lit_context'; 36 37 const MARGIN = 20; 38 const MIN_GRAPH_WIDTH = 900; 39 40 function stripInvocationPrefix(invocationName: string): string { 41 return invocationName.slice('invocations/'.length); 42 } 43 44 @customElement('milo-invocation-details-tab') 45 @consumer 46 export class InvocationDetailsTabElement extends MobxExtLitElement { 47 @observable.ref 48 @consumeStore() 49 store!: StoreInstance; 50 51 @observable.ref 52 @consumeInvocationState() 53 invState!: InvocationStateInstance; 54 55 @computed 56 private get hasTags() { 57 return (this.invState.invocation!.tags || []).length > 0; 58 } 59 60 @observable 61 private graphWidth = MIN_GRAPH_WIDTH; 62 63 @observable 64 private numRequestsCompleted = 0; 65 66 private includedInvocations: Invocation[] = []; 67 68 constructor() { 69 super(); 70 makeObservable(this); 71 this.addDisposer( 72 autorun(() => { 73 try { 74 if ( 75 !this.invState || 76 !this.invState.invocation || 77 !this.invState.invocation.includedInvocations 78 ) { 79 return; 80 } 81 let invs = this.invState.invocation.includedInvocations || []; 82 // No more than 512 requests in flight at a time to prevent browsers cancelling them 83 // TODO: implement a BatchGetInvocation call in ResultDB and remove this compensation. 84 const delayedInvs = invs.slice(512); 85 invs = invs.slice(0, 512); 86 const invocationReceivedCallback = (invocation: Invocation) => { 87 this.includedInvocations.push(invocation); 88 // this.numRequestsCompleted += 1; 89 this.batchRequestComplete(); 90 if (delayedInvs.length) { 91 this.store.services.resultDb 92 ?.getInvocation( 93 { name: delayedInvs.pop()! }, 94 { skipUpdate: true }, 95 ) 96 .then(invocationReceivedCallback) 97 .catch((e) => { 98 // TODO(mwarton): display the error to the user. 99 logging.error(e); 100 }); 101 } 102 }; 103 for (const invocationName of invs) { 104 this.store.services.resultDb 105 ?.getInvocation({ name: invocationName }, { skipUpdate: true }) 106 .then(invocationReceivedCallback) 107 .catch((e) => { 108 // TODO(mwarton): display the error to the user. 109 logging.error(e); 110 }); 111 } 112 } catch (e) { 113 // TODO(mwarton): display the error to the user. 114 logging.error(e); 115 } 116 }), 117 ); 118 } 119 120 // requestsInBatch is NOT observable so we can batch updates to the observable numRequestsCompleted. 121 private requestsInBatch = 0; 122 // equivalent to this.numRequestsCompleted += 1, but batches all of the updates until the next idle period. 123 // This ensures a render is only kicked off once the last one is finished, giving the minimum number of re-renders. 124 batchRequestComplete() { 125 if (this.requestsInBatch === 0) { 126 window.requestIdleCallback(() => { 127 this.numRequestsCompleted += this.requestsInBatch; 128 this.requestsInBatch = 0; 129 }); 130 } 131 this.requestsInBatch += 1; 132 } 133 134 private now = DateTime.now(); 135 136 connectedCallback() { 137 super.connectedCallback(); 138 this.now = DateTime.now(); 139 140 const syncWidth = () => { 141 this.graphWidth = Math.max( 142 window.innerWidth - 2 * MARGIN, 143 MIN_GRAPH_WIDTH, 144 ); 145 }; 146 window.addEventListener('resize', syncWidth); 147 this.addDisposer(() => window.removeEventListener('resize', syncWidth)); 148 syncWidth(); 149 } 150 151 protected render() { 152 const invocation = this.invState.invocation; 153 if (invocation === null) { 154 return html``; 155 } 156 157 const blocks: TimelineBlock[] = this.includedInvocations.map((i) => ({ 158 text: stripInvocationPrefix(i.name), 159 href: `/ui/inv/${stripInvocationPrefix(i.name)}/invocation-details`, 160 start: DateTime.fromISO(i.createTime), 161 end: i.finalizeTime ? DateTime.fromISO(i.finalizeTime) : undefined, 162 })); 163 blocks.sort((a, b) => { 164 if (a.end && (!b.end || a.end < b.end)) { 165 return -1; 166 } else if (b.end && (!a.end || a.end > b.end)) { 167 return 1; 168 } else { 169 // Invocations always have a create time, no need for undefined checks here. 170 return a.start!.toMillis() - b.start!.toMillis(); 171 } 172 }); 173 return html` 174 <div> 175 Create Time: ${new Date(invocation.createTime).toLocaleString()} 176 </div> 177 <div> 178 Finalize Time: ${new Date(invocation.finalizeTime).toLocaleString()} 179 </div> 180 <div>Deadline: ${new Date(invocation.deadline).toLocaleDateString()}</div> 181 <div style=${styleMap({ display: this.hasTags ? '' : 'none' })}> 182 Tags: 183 <table id="tag-table" border="0"> 184 ${invocation.tags?.map( 185 (tag) => html` 186 <tr> 187 <td>${tag.key}:</td> 188 <td>${tag.value}</td> 189 </tr> 190 `, 191 )} 192 </table> 193 </div> 194 <div id="included-invocations"> 195 ${invocation.includedInvocations?.length 196 ? html`Included Invocations: (loaded ${this.numRequestsCompleted} of 197 ${invocation.includedInvocations?.length}) 198 <milo-timeline 199 .width=${this.graphWidth} 200 .startTime=${DateTime.fromISO(invocation.createTime)} 201 .endTime=${invocation.finalizeTime 202 ? DateTime.fromISO(invocation.finalizeTime) 203 : this.now} 204 .blocks=${blocks} 205 > 206 </milo-timeline>` 207 : 'Included Invocations: None'} 208 </div> 209 `; 210 } 211 212 static styles = [ 213 commonStyles, 214 css` 215 :host { 216 display: block; 217 padding: 10px 20px; 218 } 219 220 #included-invocations ul { 221 list-style-type: none; 222 margin-block-start: auto; 223 margin-block-end: auto; 224 padding-inline-start: 32px; 225 } 226 227 #tag-table { 228 margin-left: 29px; 229 } 230 `, 231 ]; 232 } 233 234 declare global { 235 // eslint-disable-next-line @typescript-eslint/no-namespace 236 namespace JSX { 237 interface IntrinsicElements { 238 'milo-invocation-details-tab': Record<string, never>; 239 } 240 } 241 } 242 243 export function InvocationDetailsTab() { 244 return <milo-invocation-details-tab />; 245 } 246 247 export function Component() { 248 useTabId('invocation-details'); 249 250 return ( 251 // See the documentation for `<LoginPage />` for why we handle error this 252 // way. 253 <RecoverableErrorBoundary key="invocation-details"> 254 <InvocationDetailsTab /> 255 </RecoverableErrorBoundary> 256 ); 257 }