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>;