go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/store/test_history_page.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 { interpolateOranges, scaleLinear, scaleSequential } from 'd3';
    16  import { DateTime } from 'luxon';
    17  import { autorun, comparer, computed } from 'mobx';
    18  import { addDisposer, types } from 'mobx-state-tree';
    19  
    20  import { PageLoader } from '@/common/models/page_loader';
    21  import { TestHistoryStatsLoader } from '@/common/models/test_history_stats_loader';
    22  import {
    23    parseVariantFilter,
    24    parseVariantPredicate,
    25    VariantFilter,
    26  } from '@/common/queries/th_filter_query';
    27  import {
    28    QueryTestHistoryStatsResponseGroup,
    29    TestVerdictBundle,
    30    TestVerdictStatus,
    31    Variant,
    32    VariantPredicate,
    33  } from '@/common/services/luci_analysis';
    34  import { ServicesStore } from '@/common/store/services';
    35  import { Timestamp } from '@/common/store/timestamp';
    36  import { logging } from '@/common/tools/logging';
    37  import { keepAliveComputed } from '@/generic_libs/tools/mobx_utils';
    38  import { getCriticalVariantKeys } from '@/test_verdict/tools/variant_utils/variant_utils';
    39  
    40  export const enum GraphType {
    41    STATUS = 'STATUS',
    42    DURATION = 'DURATION',
    43  }
    44  
    45  export const enum XAxisType {
    46    DATE = 'DATE',
    47    COMMIT = 'COMMIT',
    48  }
    49  
    50  // Use `scaleColor` to discard colors avoid using white color when the input is
    51  // close to 0.
    52  const scaleColor = scaleLinear().range([0.1, 1]).domain([0, 1]);
    53  
    54  export const TestHistoryPage = types
    55    .model('TestHistoryPage', {
    56      refreshTime: types.safeReference(Timestamp),
    57      services: types.safeReference(ServicesStore),
    58  
    59      project: types.maybe(types.string),
    60      subRealm: types.maybe(types.string),
    61      testId: types.maybe(types.string),
    62  
    63      days: 14,
    64      filterText: '',
    65  
    66      selectedGroup: types.frozen<QueryTestHistoryStatsResponseGroup | null>(
    67        null,
    68      ),
    69  
    70      graphType: types.frozen<GraphType>(GraphType.STATUS),
    71      xAxisType: types.frozen<XAxisType>(XAxisType.DATE),
    72  
    73      countUnexpected: true,
    74      countUnexpectedlySkipped: true,
    75      countFlaky: true,
    76      countExonerated: true,
    77  
    78      durationInitialized: false,
    79      minDurationMs: 0,
    80      maxDurationMs: 100,
    81  
    82      defaultSortingKeys: types.frozen<readonly string[]>([]),
    83      customColumnKeys: types.frozen<readonly string[]>([]),
    84      customColumnWidths: types.frozen<{ readonly [key: string]: number }>({}),
    85      customSortingKeys: types.frozen<readonly string[]>([]),
    86    })
    87    .volatile(() => ({
    88      variantFilter: ((_v: Variant, _hash: string) => true) as VariantFilter,
    89      variantPredicate: { contains: { def: {} } } as VariantPredicate,
    90    }))
    91    .views((self) => ({
    92      get latestDate() {
    93        return (self.refreshTime?.dateTime || DateTime.now())
    94          .toUTC()
    95          .startOf('day');
    96      },
    97      get scaleDurationColor() {
    98        return scaleSequential((x) => interpolateOranges(scaleColor(x))).domain([
    99          self.minDurationMs,
   100          self.maxDurationMs,
   101        ]);
   102      },
   103    }))
   104    .views((self) => {
   105      const variantsLoader = keepAliveComputed(self, () => {
   106        if (!self.project || !self.testId || !self.services?.testHistory) {
   107          return null;
   108        }
   109  
   110        // Establish dependencies so the loader will get re-computed correctly.
   111        const project = self.project;
   112        const subRealm = self.subRealm;
   113        const testId = self.testId;
   114        const variantPredicate = self.variantPredicate;
   115        const testHistoryService = self.services.testHistory;
   116  
   117        return new PageLoader(async (pageToken) => {
   118          const res = await testHistoryService.queryVariants({
   119            project,
   120            subRealm,
   121            testId,
   122            variantPredicate,
   123            pageToken,
   124          });
   125          return [
   126            res.variants?.map(
   127              (v) =>
   128                [v.variantHash, v.variant || { def: {} }] as [string, Variant],
   129            ) || [],
   130            res.nextPageToken,
   131          ];
   132        });
   133      });
   134  
   135      const statsLoader = keepAliveComputed(self, () => {
   136        if (!self.project || !self.testId || !self.services?.testHistory) {
   137          return null;
   138        }
   139        return new TestHistoryStatsLoader(
   140          self.project,
   141          self.subRealm || '',
   142          self.testId,
   143          self.latestDate,
   144          self.variantPredicate,
   145          self.services.testHistory,
   146        );
   147      });
   148  
   149      const entriesLoader = keepAliveComputed(self, () => {
   150        if (
   151          !self.project ||
   152          !self.testId ||
   153          !self.selectedGroup?.variantHash ||
   154          !self.selectedGroup?.partitionTime ||
   155          !self.services?.testHistory
   156        ) {
   157          return null;
   158        }
   159        const varLoader = variantsLoader.get();
   160        if (!varLoader) {
   161          return null;
   162        }
   163  
   164        // Establish dependencies so the loader will get re-computed correctly.
   165        const project = self.project;
   166        const subRealm = self.subRealm;
   167        const testId = self.testId;
   168        const variantHash = self.selectedGroup?.variantHash;
   169        const earliest = self.selectedGroup?.partitionTime;
   170        const testHistoryService = self.services.testHistory;
   171        const variant = varLoader.items.find(
   172          ([vHash]) => vHash === variantHash,
   173        )![1];
   174        const latest = DateTime.fromISO(earliest).minus({ days: -1 }).toISO();
   175  
   176        return new PageLoader(async (pageToken) => {
   177          const res = await testHistoryService.query({
   178            project,
   179            testId,
   180            predicate: {
   181              subRealm,
   182              variantPredicate: {
   183                equals: variant,
   184              },
   185              partitionTimeRange: {
   186                earliest,
   187                latest,
   188              },
   189            },
   190            pageSize: 100,
   191            pageToken,
   192          });
   193          return [
   194            res.verdicts?.map((verdict) => ({ verdict, variant: variant })) || [],
   195            res.nextPageToken,
   196          ];
   197        });
   198      });
   199  
   200      return {
   201        get variantsLoader() {
   202          return variantsLoader.get();
   203        },
   204        get statsLoader() {
   205          return statsLoader.get();
   206        },
   207        get entriesLoader() {
   208          return entriesLoader.get();
   209        },
   210      };
   211    })
   212    .views((self) => {
   213      const criticalVariantKeys = computed(
   214        () =>
   215          getCriticalVariantKeys(
   216            self.variantsLoader?.items.map(([_, v]) => v) || [],
   217          ),
   218        { equals: comparer.shallow },
   219      );
   220  
   221      return {
   222        get filteredVariants() {
   223          return (
   224            self.variantsLoader?.items.filter(([hash, v]) =>
   225              self.variantFilter(v, hash),
   226            ) || []
   227          );
   228        },
   229        get criticalVariantKeys(): readonly string[] {
   230          return criticalVariantKeys.get();
   231        },
   232        get defaultColumnKeys(): readonly string[] {
   233          return this.criticalVariantKeys.map((k) => 'v.' + k);
   234        },
   235        get columnKeys(): readonly string[] {
   236          return self.customColumnKeys || this.defaultColumnKeys;
   237        },
   238        get columnWidths(): readonly number[] {
   239          return this.columnKeys.map(
   240            (col) => self.customColumnWidths[col] ?? 100,
   241          );
   242        },
   243        get sortingKeys(): readonly string[] {
   244          return self.customSortingKeys || self.defaultSortingKeys;
   245        },
   246        get verdictBundles() {
   247          if (!self.entriesLoader?.items.length) {
   248            return [];
   249          }
   250  
   251          const cmpFn = createTVCmpFn(this.sortingKeys);
   252          return [...(self.entriesLoader?.items || [])].sort(cmpFn);
   253        },
   254        get selectedTestVerdictCount() {
   255          return (
   256            (self.selectedGroup?.unexpectedCount || 0) +
   257            (self.selectedGroup?.unexpectedlySkippedCount || 0) +
   258            (self.selectedGroup?.flakyCount || 0) +
   259            (self.selectedGroup?.exoneratedCount || 0) +
   260            (self.selectedGroup?.expectedCount || 0)
   261          );
   262        },
   263      };
   264    })
   265    .actions((self) => ({
   266      setDependencies(deps: Pick<typeof self, 'refreshTime' | 'services'>) {
   267        Object.assign(self, deps);
   268      },
   269      setParams(projectOrRealm: string, testId: string) {
   270        [self.project, self.subRealm] = projectOrRealm.split(':', 2);
   271        self.testId = testId;
   272      },
   273      setFilterText(filterText: string) {
   274        self.filterText = filterText;
   275      },
   276      setSelectedGroup(group: QueryTestHistoryStatsResponseGroup | null) {
   277        self.selectedGroup = group;
   278      },
   279      setDays(days: number) {
   280        self.days = days;
   281      },
   282      setGraphType(type: GraphType) {
   283        self.graphType = type;
   284      },
   285      setXAxisType(type: XAxisType) {
   286        self.xAxisType = type;
   287      },
   288      setCountUnexpected(count: boolean) {
   289        self.countUnexpected = count;
   290      },
   291      setCountUnexpectedlySkipped(count: boolean) {
   292        self.countUnexpectedlySkipped = count;
   293      },
   294      setCountFlaky(count: boolean) {
   295        self.countFlaky = count;
   296      },
   297      setCountExonerated(count: boolean) {
   298        self.countExonerated = count;
   299      },
   300      setDuration(durationMs: number) {
   301        if (self.durationInitialized) {
   302          self.minDurationMs = durationMs;
   303          self.maxDurationMs = durationMs;
   304          return;
   305        }
   306        self.minDurationMs = Math.min(self.minDurationMs, durationMs);
   307        self.maxDurationMs = Math.max(self.maxDurationMs, durationMs);
   308      },
   309      resetDurations() {
   310        self.durationInitialized = false;
   311        self.minDurationMs = 0;
   312        self.maxDurationMs = 100;
   313      },
   314      setColumnKeys(v: readonly string[]) {
   315        self.customColumnKeys = v;
   316      },
   317      setColumnWidths(v: { readonly [key: string]: number }) {
   318        self.customColumnWidths = v;
   319      },
   320      setSortingKeys(v: readonly string[]): void {
   321        self.customSortingKeys = v;
   322      },
   323      _updateFilters(filter: VariantFilter, predicate: VariantPredicate) {
   324        self.variantFilter = filter;
   325        self.variantPredicate = predicate;
   326      },
   327      afterCreate() {
   328        addDisposer(
   329          self,
   330          autorun(() => {
   331            try {
   332              const newVariantFilter = parseVariantFilter(self.filterText);
   333              const newVariantPredicate = parseVariantPredicate(self.filterText);
   334  
   335              // Only update the filters after the query is successfully parsed.
   336              this._updateFilters(newVariantFilter, newVariantPredicate);
   337            } catch (e) {
   338              // TODO(weiweilin): display the error to the user.
   339              logging.error(e);
   340            }
   341          }),
   342        );
   343      },
   344    }));
   345  
   346  // Note: once we have more than 9 statuses, we need to add '0' prefix so '10'
   347  // won't appear before '2' after sorting.
   348  export const TEST_VERDICT_STATUS_CMP_STRING = {
   349    [TestVerdictStatus.TEST_VERDICT_STATUS_UNSPECIFIED]: '0',
   350    [TestVerdictStatus.UNEXPECTED]: '1',
   351    [TestVerdictStatus.UNEXPECTEDLY_SKIPPED]: '2',
   352    [TestVerdictStatus.FLAKY]: '3',
   353    [TestVerdictStatus.EXONERATED]: '4',
   354    [TestVerdictStatus.EXPECTED]: '5',
   355  };
   356  /**
   357   * Create a test variant compare function for the given sorting key list.
   358   *
   359   * A sorting key must be one of the following:
   360   * 1. '{property_key}': sort by property_key in ascending order.
   361   * 2. '-{property_key}': sort by property_key in descending order.
   362   */
   363  export function createTVCmpFn(
   364    sortingKeys: readonly string[],
   365  ): (v1: TestVerdictBundle, v2: TestVerdictBundle) => number {
   366    const sorters: Array<
   367      [number, (v: TestVerdictBundle) => { toString(): string }]
   368    > = sortingKeys.map((key) => {
   369      const [mul, propKey] = key.startsWith('-') ? [-1, key.slice(1)] : [1, key];
   370      const propGetter = createTVPropGetter(propKey);
   371  
   372      // Status should be be sorted by their significance not by their string
   373      // representation.
   374      if (propKey.toLowerCase() === 'status') {
   375        return [
   376          mul,
   377          (v) =>
   378            TEST_VERDICT_STATUS_CMP_STRING[propGetter(v) as TestVerdictStatus],
   379        ];
   380      }
   381      return [mul, propGetter];
   382    });
   383    return (v1, v2) => {
   384      for (const [mul, propGetter] of sorters) {
   385        const cmp =
   386          propGetter(v1).toString().localeCompare(propGetter(v2).toString()) *
   387          mul;
   388        if (cmp !== 0) {
   389          return cmp;
   390        }
   391      }
   392      return 0;
   393    };
   394  }
   395  
   396  /**
   397   * Create a test verdict property getter for the given property key.
   398   *
   399   * A property key must be one of the following:
   400   * 1. 'status': status of the test verdict.
   401   * 2. 'partitionTime': partition time of the test verdict.
   402   * 3. 'v.{variant_key}': def[variant_key] of associated variant of the test
   403   * verdict (e.g. v.gpu).
   404   */
   405  export function createTVPropGetter(
   406    propKey: string,
   407  ): (v: TestVerdictBundle) => ToString {
   408    if (propKey.match(/^v[.]/i)) {
   409      const variantKey = propKey.slice(2);
   410      return ({ variant }) => variant.def[variantKey] || '';
   411    }
   412    propKey = propKey.toLowerCase();
   413    switch (propKey) {
   414      case 'status':
   415        return ({ verdict }) => verdict.status;
   416      case 'patitiontime':
   417        return ({ verdict }) => verdict.partitionTime;
   418      default:
   419        logging.warn('invalid property key', propKey);
   420        return () => '';
   421    }
   422  }