go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/store/build_state/build_state.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 { render } from 'lit'; 16 import { unsafeHTML } from 'lit/directives/unsafe-html.js'; 17 import { DateTime, Duration } from 'luxon'; 18 import { action, computed, makeObservable, untracked } from 'mobx'; 19 import { Instance, SnapshotIn, SnapshotOut, types } from 'mobx-state-tree'; 20 21 import { 22 BLAMELIST_PIN_KEY, 23 Build, 24 BuildbucketStatus, 25 getAssociatedGitilesCommit, 26 Log, 27 Step, 28 } from '@/common/services/buildbucket'; 29 import { GitilesCommit, StringPair } from '@/common/services/common'; 30 import { Timestamp, TimestampInstance } from '@/common/store/timestamp'; 31 import { UserConfig, UserConfigInstance } from '@/common/store/user_config'; 32 import { renderMarkdown } from '@/common/tools/markdown/utils'; 33 import { parseProtoDurationStr } from '@/common/tools/time_utils'; 34 import { keepAliveComputed } from '@/generic_libs/tools/mobx_utils'; 35 36 export interface StepInit { 37 step: Step; 38 depth: number; 39 index: number; 40 selfName: string; 41 listNumber: string; 42 userConfig?: UserConfigInstance; 43 currentTime?: TimestampInstance; 44 } 45 46 /** 47 * Contains all fields of the Step object with added helper methods and 48 * properties. 49 */ 50 export class StepExt { 51 readonly name: string; 52 readonly startTime: DateTime | null; 53 readonly endTime: DateTime | null; 54 readonly status: BuildbucketStatus; 55 readonly logs: readonly Log[]; 56 readonly summaryMarkdown?: string | undefined; 57 readonly tags: readonly StringPair[]; 58 59 readonly depth: number; 60 readonly index: number; 61 readonly selfName: string; 62 readonly listNumber: string; 63 readonly children: StepExt[] = []; 64 65 readonly userConfig?: UserConfigInstance; 66 readonly currentTime?: TimestampInstance; 67 68 constructor(init: StepInit) { 69 makeObservable(this); 70 this.userConfig = init.userConfig; 71 72 const step = init.step; 73 this.name = step.name; 74 this.startTime = step.startTime ? DateTime.fromISO(step.startTime) : null; 75 this.endTime = step.endTime ? DateTime.fromISO(step.endTime) : null; 76 this.status = step.status; 77 this.logs = step.logs || []; 78 this.summaryMarkdown = step.summaryMarkdown; 79 this.tags = step.tags || []; 80 81 this.depth = init.depth; 82 this.index = init.index; 83 this.selfName = init.selfName; 84 this.listNumber = init.listNumber; 85 this.currentTime = init.currentTime; 86 } 87 88 @computed private get _currentTime() { 89 return this.currentTime?.dateTime || DateTime.now(); 90 } 91 92 @computed get duration() { 93 if (!this.startTime) { 94 return Duration.fromMillis(0); 95 } 96 return (this.endTime || this._currentTime).diff(this.startTime); 97 } 98 99 /** 100 * summary renders summaryMarkdown into HTML. 101 */ 102 // TODO(weiweilin): we should move this to build_step.ts because it contains 103 // rendering logic. 104 @computed get summary() { 105 const bodyContainer = document.createElement('div'); 106 render( 107 unsafeHTML(renderMarkdown(this.summaryMarkdown || '')), 108 bodyContainer, 109 ); 110 // The body has no content. 111 // We don't need to check bodyContainer.firstChild because text are 112 // automatically wrapped in <p>. 113 if (bodyContainer.firstElementChild === null) { 114 return null; 115 } 116 117 // Show a tooltip in case the header content is cutoff. 118 bodyContainer.title = bodyContainer.textContent || ''; 119 120 // If the container is empty, return null instead. 121 return bodyContainer; 122 } 123 124 @computed get filteredLogs() { 125 const logs = this.logs || []; 126 127 return this.userConfig?.build.steps.showDebugLogs ?? true 128 ? logs 129 : logs.filter((log) => !log.name.startsWith('$')); 130 } 131 132 @computed get isPinned() { 133 return this.userConfig?.build.steps.stepIsPinned(this.name); 134 } 135 136 @action setIsPinned(pinned: boolean) { 137 this.userConfig?.build.steps.setStepPin(this.name, pinned); 138 } 139 140 @computed get isCritical() { 141 return this.status !== BuildbucketStatus.Success || this.isPinned; 142 } 143 /** 144 * true if and only if the step and all of its descendants succeeded. 145 */ 146 @computed get succeededRecursively(): boolean { 147 if (this.status !== BuildbucketStatus.Success) { 148 return false; 149 } 150 return this.children.every((child) => child.succeededRecursively); 151 } 152 153 /** 154 * true iff the step or one of its descendants failed (status Failure or InfraFailure). 155 */ 156 @computed get failed(): boolean { 157 if ( 158 this.status === BuildbucketStatus.Failure || 159 this.status === BuildbucketStatus.InfraFailure 160 ) { 161 return true; 162 } 163 return this.children.some((child) => child.failed); 164 } 165 166 @computed({ keepAlive: true }) get clusteredChildren() { 167 return clusterBuildSteps(this.children); 168 } 169 } 170 171 /** 172 * Split the steps into multiple groups such that each group maximally contains 173 * consecutive steps that share the same criticality. 174 * 175 * Note that this function intentionally does not react to (i.e. untrack) the 176 * steps' criticality, so that a change it the steps' criticality (e.g. when 177 * the step is pinned/unpinned) does not trigger a rerun of the function. 178 */ 179 export function clusterBuildSteps( 180 steps: readonly StepExt[], 181 ): readonly (readonly StepExt[])[] { 182 const clusters: StepExt[][] = []; 183 for (const step of steps) { 184 let lastCluster = clusters[clusters.length - 1]; 185 186 // Do not react to the change of a step's criticality because updating the 187 // cluster base on the internal state of the step is confusing. 188 // e.g. it can leads to the steps being re-clustered when users (un)pin a 189 // step. 190 const criticalityChanged = untracked( 191 () => step.isCritical !== lastCluster?.[0]?.isCritical, 192 ); 193 194 if (criticalityChanged) { 195 lastCluster = []; 196 clusters.push(lastCluster); 197 } 198 lastCluster.push(step); 199 } 200 return clusters; 201 } 202 203 export const BuildState = types 204 .model('BuildState', { 205 id: types.optional(types.identifierNumber, () => Math.random()), 206 data: types.frozen<Build>(), 207 currentTime: types.safeReference(Timestamp), 208 userConfig: types.safeReference(UserConfig), 209 }) 210 .volatile(() => ({ 211 steps: [] as readonly StepExt[], 212 rootSteps: [] as readonly StepExt[], 213 })) 214 .views((self) => ({ 215 get createTime() { 216 return DateTime.fromISO(self.data.createTime); 217 }, 218 get startTime() { 219 return self.data.startTime ? DateTime.fromISO(self.data.startTime) : null; 220 }, 221 get endTime() { 222 return self.data.endTime ? DateTime.fromISO(self.data.endTime) : null; 223 }, 224 get cancelTime() { 225 return self.data.cancelTime 226 ? DateTime.fromISO(self.data.cancelTime) 227 : null; 228 }, 229 get schedulingTimeout() { 230 return self.data.schedulingTimeout 231 ? parseProtoDurationStr(self.data.schedulingTimeout) 232 : null; 233 }, 234 get executionTimeout() { 235 return self.data.executionTimeout 236 ? parseProtoDurationStr(self.data.executionTimeout) 237 : null; 238 }, 239 get gracePeriod() { 240 return self.data.gracePeriod 241 ? parseProtoDurationStr(self.data.gracePeriod) 242 : null; 243 }, 244 get buildOrStepInfraFailed() { 245 return ( 246 self.data.status === BuildbucketStatus.InfraFailure || 247 self.steps.some((s) => s.status === BuildbucketStatus.InfraFailure) 248 ); 249 }, 250 get buildNumOrId() { 251 return self.data.number?.toString() || 'b' + self.data.id; 252 }, 253 get isCanary() { 254 return Boolean( 255 self.data.input?.experiments?.includes( 256 'luci.buildbucket.canary_software', 257 ), 258 ); 259 }, 260 get associatedGitilesCommit() { 261 return getAssociatedGitilesCommit(self.data); 262 }, 263 get blamelistPins(): readonly GitilesCommit[] { 264 const blamelistPins = 265 self.data.output?.properties?.[BLAMELIST_PIN_KEY] || []; 266 if (blamelistPins.length === 0 && this.associatedGitilesCommit) { 267 blamelistPins.push(this.associatedGitilesCommit); 268 } 269 return blamelistPins; 270 }, 271 get recipeLink() { 272 let csHost = 'source.chromium.org'; 273 if (self.data.exe?.cipdPackage?.includes('internal')) { 274 csHost = 'source.corp.google.com'; 275 } 276 // TODO(crbug.com/1149540): remove this conditional once the long-term 277 // solution for recipe links has been implemented. 278 if (self.data.builder.project === 'flutter') { 279 csHost = 'cs.opensource.google'; 280 } 281 const recipeName = self.data.input?.properties?.['recipe']; 282 if (!recipeName) { 283 return null; 284 } 285 286 return { 287 label: recipeName as string, 288 url: `https://${csHost}/search/?${new URLSearchParams([ 289 ['q', `file:recipes/${recipeName}.py`], 290 ]).toString()}`, 291 ariaLabel: `recipe ${recipeName}`, 292 }; 293 }, 294 get _currentTime() { 295 return self.currentTime?.dateTime || DateTime.now(); 296 }, 297 get pendingDuration() { 298 return (this.startTime || this.endTime || this._currentTime).diff( 299 this.createTime, 300 ); 301 }, 302 get isPending() { 303 return !this.startTime && !this.endTime; 304 }, 305 /** 306 * A build exceeded it's scheduling timeout when 307 * - the build is canceled, AND 308 * - the build did not enter the execution phase, AND 309 * - the scheduling timeout is specified, AND 310 * - the pending duration is no less than the scheduling timeout. 311 */ 312 get exceededSchedulingTimeout() { 313 return ( 314 !this.startTime && 315 !this.isPending && 316 this.schedulingTimeout !== null && 317 this.pendingDuration >= this.schedulingTimeout 318 ); 319 }, 320 get executionDuration() { 321 return this.startTime 322 ? (this.endTime || this._currentTime).diff(this.startTime) 323 : null; 324 }, 325 get isExecuting() { 326 return this.startTime !== null && !this.endTime; 327 }, 328 /** 329 * A build exceeded it's execution timeout when 330 * - the build is canceled, AND 331 * - the build had entered the execution phase, AND 332 * - the execution timeout is specified, AND 333 * - the execution duration is no less than the execution timeout. 334 */ 335 get exceededExecutionTimeout(): boolean { 336 return ( 337 self.data.status === BuildbucketStatus.Canceled && 338 this.executionDuration !== null && 339 this.executionTimeout !== null && 340 this.executionDuration >= this.executionTimeout 341 ); 342 }, 343 get timeSinceCreated(): Duration { 344 return this._currentTime.diff(this.createTime); 345 }, 346 get timeSinceStarted(): Duration | null { 347 return this.startTime ? this._currentTime.diff(this.startTime) : null; 348 }, 349 get timeSinceEnded(): Duration | null { 350 return this.endTime ? this._currentTime.diff(this.endTime) : null; 351 }, 352 })) 353 .views((self) => { 354 const clusteredRootSteps = keepAliveComputed(self, () => 355 clusterBuildSteps(self.rootSteps), 356 ); 357 return { 358 get clusteredRootSteps() { 359 return clusteredRootSteps.get(); 360 }, 361 }; 362 }) 363 .actions((self) => ({ 364 afterCreate() { 365 const steps: StepExt[] = []; 366 const rootSteps: StepExt[] = []; 367 368 // Map step name -> StepExt. 369 const stepMap = new Map<string, StepExt>(); 370 371 // Build the step-tree. 372 for (const step of self.data.steps || []) { 373 const splitName = step.name.split('|'); 374 // There must be at least one element in a split string array. 375 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 376 const selfName = splitName.pop()!; 377 const depth = splitName.length; 378 const parentName = splitName.join('|'); 379 const parent = stepMap.get(parentName); 380 381 const index = (parent?.children || rootSteps).length; 382 const listNumber = `${parent?.listNumber || ''}${index + 1}.`; 383 const stepState = new StepExt({ 384 step, 385 listNumber, 386 depth, 387 index, 388 selfName, 389 currentTime: self.currentTime, 390 userConfig: self.userConfig, 391 }); 392 393 steps.push(stepState); 394 stepMap.set(step.name, stepState); 395 if (!parent) { 396 rootSteps.push(stepState); 397 } else { 398 parent.children.push(stepState); 399 } 400 } 401 402 self.steps = steps; 403 self.rootSteps = rootSteps; 404 }, 405 })); 406 407 export type BuildStateInstance = Instance<typeof BuildState>; 408 export type BuildStateSnapshotIn = SnapshotIn<typeof BuildState>; 409 export type BuildStateSnapshotOut = SnapshotOut<typeof BuildState>;