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 }