1// Copyright 2024 The Chromium Authors 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import {assert} from 'chrome://resources/js/assert.js'; 6import {CustomElement} from 'chrome://resources/js/custom_element.js'; 7import {getTrustedHTML} from 'chrome://resources/js/static_types.js'; 8 9import type {FieldTrialState, Group, HashNamed, HashNameMap, MetricsInternalsBrowserProxy, Trial} from './browser_proxy.js'; 10import {MetricsInternalsBrowserProxyImpl} from './browser_proxy.js'; 11import {getTemplate} from './field_trials.html.js'; 12 13// Stores and persists study and group names along with their hash. 14class NameUnhasher { 15 private hashNames: Map<string, string> = 16 new Map(JSON.parse(localStorage.getItem('names') || '[]')); 17 // We remember up to this.maxStoredNames names in localStorage. 18 private readonly maxStoredNames = 500; 19 20 add(names: HashNameMap): boolean { 21 let changed = false; 22 for (const [hash, name] of Object.entries(names)) { 23 if (name && !this.hashNames.has(hash)) { 24 changed = true; 25 this.hashNames.set(hash, name); 26 } 27 } 28 if (changed) { 29 // Note: `Map` retains item order, so this keeps the most recent 30 // `maxStoredNames` entries. 31 localStorage.setItem( 32 'names', 33 JSON.stringify(Array.from(this.hashNames.entries()) 34 .slice(-this.maxStoredNames))); 35 } 36 return changed; 37 } 38 39 displayName(named: HashNamed): string { 40 const name = named.name || this.hashNames.get(named.hash); 41 return name ? `${name} (#${named.hash})` : `#${named.hash}`; 42 } 43} 44 45class SearchFilter { 46 private searchParts: Array<[string, string]> = []; 47 48 constructor(private unhasher: NameUnhasher, private searchText: string) { 49 this.searchText = searchText.toLowerCase(); 50 // Allow any of these separators. This means we may need to consider more 51 // than one interpretation. For example: "One.Two-Three" could be a single 52 // name, or one of the trial/group combinations "One/Two-Three", 53 // "One.Two/Three". 54 for (const separator of '/.:-') { 55 const parts = this.searchText.split(separator); 56 if (parts.length === 2) { 57 this.searchParts.push(parts as [string, string]); 58 } 59 } 60 } 61 62 match(named: HashNamed, checkParts: boolean): MatchResult { 63 if (this.searchText === '') { 64 return MatchResult.NONE; 65 } 66 let match = this.matchNameOrHash(this.searchText, named); 67 if (!match && checkParts) { 68 for (const parts of this.searchParts) { 69 match = this.matchNameOrHash(parts['groups' in named ? 0 : 1]!, named); 70 if (match) { 71 break; 72 } 73 } 74 } 75 return match ? MatchResult.MATCH : MatchResult.MISMATCH; 76 } 77 78 private matchNameOrHash(search: string, subject: HashNamed): boolean { 79 return this.unhasher.displayName(subject).toLowerCase().includes(search); 80 } 81} 82 83enum MatchResult { 84 // There is no search query. 85 NONE = '', 86 // Matched the search. 87 MATCH = 'match', 88 // Did not match the search. 89 MISMATCH = 'no-match', 90} 91 92export class TrialRow { 93 root: HTMLDivElement; 94 overridden = false; 95 experimentRows: ExperimentRow[] = []; 96 97 constructor(private app: FieldTrialsAppElement, public trial: Trial) { 98 this.root = document.createElement('div'); 99 this.root.classList.add('trial-row'); 100 this.root.innerHTML = getTrustedHTML` 101 <div class="trial-header"> 102 <button class="expand-button"></button> 103 <span class="trial-name"></span> 104 </div> 105 <div class="trial-groups"></div>`; 106 107 for (const group of trial.groups) { 108 this.overridden = this.overridden || group.forceEnabled; 109 const experimentRow = new ExperimentRow(this.app, trial, group); 110 this.experimentRows.push(experimentRow); 111 } 112 113 this.root.querySelector('.trial-groups')!.replaceChildren( 114 ...this.experimentRows.map(r => r.root)); 115 this.root.querySelector('.expand-button')!.addEventListener('click', () => { 116 const dataset = this.root.dataset; 117 dataset['expanded'] = String(dataset['expanded'] !== 'true'); 118 }); 119 } 120 121 update() { 122 this.root.querySelector('.trial-name')!.replaceChildren( 123 this.app.unhasher.displayName(this.trial)); 124 for (const row of this.experimentRows) { 125 row.update(); 126 } 127 } 128 129 findExperimentRow(groupHash: string): ExperimentRow|undefined { 130 return this.experimentRows.find(row => row.group.hash === groupHash); 131 } 132 133 setMatchResult(result: MatchResult) { 134 this.root.dataset['searchResult'] = result; 135 } 136 137 filter(searchFilter: SearchFilter): [boolean, number] { 138 let matches = 0; 139 let trialResult: MatchResult = searchFilter.match(this.trial, true); 140 for (const row of this.experimentRows) { 141 const result = 142 searchFilter.match(row.group, trialResult === MatchResult.MATCH); 143 row.setMatchResult(result); 144 if (result === MatchResult.MATCH) { 145 trialResult = MatchResult.MATCH; 146 matches++; 147 } 148 } 149 this.root.dataset['searchResult'] = trialResult; 150 return [trialResult === MatchResult.MATCH, matches]; 151 } 152 153 displayName(): string { 154 return this.app.unhasher.displayName(this.trial); 155 } 156 157 sortKey(): string { 158 const name = this.displayName(); 159 // Order: Overridden trials, trials with names, trials with hash only. 160 return `${Number(!this.overridden)}${Number(name.startsWith('#'))}${name}`; 161 } 162} 163 164class ExperimentRow { 165 root: HTMLDivElement; 166 167 constructor( 168 private app: FieldTrialsAppElement, public trial: Trial, 169 public group: Group) { 170 this.root = document.createElement('div'); 171 this.root.classList.add('experiment-row'); 172 this.root.innerHTML = getTrustedHTML` 173 <div class="experiment-name"></div> 174 <div class="override"> 175 <label for="override"> 176 Override <input type="checkbox" name="override"> 177 </label> 178 </div>`; 179 if (group.enabled) { 180 this.root.dataset['enrolled'] = '1'; 181 } 182 if (group.forceEnabled) { 183 this.setForceEnabled(true); 184 } 185 this.update(); 186 this.root.querySelector('input')!.addEventListener( 187 'click', () => this.app.toggleForceEnable(trial, group)); 188 } 189 190 update() { 191 this.root.querySelector('.experiment-name')!.replaceChildren( 192 this.app.unhasher.displayName(this.group)); 193 } 194 195 setForceEnabled(forceEnabled: boolean) { 196 this.group.forceEnabled = forceEnabled; 197 const checkbox = this.root.querySelector('input')!; 198 checkbox.checked = forceEnabled; 199 if (forceEnabled) { 200 checkbox.dataset['overridden'] = '1'; 201 } else { 202 delete checkbox.dataset['overridden']; 203 } 204 } 205 206 setMatchResult(result: MatchResult) { 207 this.root.dataset['searchResult'] = result; 208 } 209} 210 211interface ElementIdMap { 212 'restart-button': HTMLElement; 213 'needs-restart': HTMLElement; 214 'filter': HTMLInputElement; 215 'filter-status': HTMLElement; 216 'field-trial-list': HTMLElement; 217} 218 219export class FieldTrialsAppElement extends CustomElement { 220 static get is(): string { 221 return 'field-trials-app'; 222 } 223 224 static override get template() { 225 return getTemplate(); 226 } 227 228 private proxy_: MetricsInternalsBrowserProxy = 229 MetricsInternalsBrowserProxyImpl.getInstance(); 230 231 // Whether changes require dom updates. Visible for testing. 232 dirty = true; 233 // The list of available trials. 234 private trials: TrialRow[] = []; 235 unhasher = new NameUnhasher(); 236 237 onUpdateForTesting = () => {}; 238 239 private el<K extends keyof ElementIdMap>(id: K): ElementIdMap[K] { 240 const result = this.shadowRoot!.getElementById(id) as any; 241 assert(result); 242 return result; 243 } 244 245 constructor() { 246 super(); 247 // Initialize only when this element is first visible. 248 new Promise<void>(resolve => { 249 const observer = new IntersectionObserver((entries) => { 250 if (entries.filter(entry => entry.intersectionRatio > 0).length > 0) { 251 resolve(); 252 } 253 }); 254 observer.observe(this); 255 }).then(() => { 256 this.init_(); 257 }); 258 } 259 260 private init_() { 261 this.proxy_.fetchTrialState().then(state => this.populateState_(state)); 262 263 // We're using a form to get autocomplete functionality, but don't need 264 // submit behavior. 265 this.getRequiredElement('form').addEventListener( 266 'submit', (e) => e.preventDefault()); 267 268 this.filterInputElement.value = localStorage.getItem('filter') || ''; 269 this.filterInputElement.addEventListener( 270 'input', () => this.filterUpdated_()); 271 this.el('restart-button') 272 .addEventListener('click', () => this.proxy_.restart()); 273 this.filterUpdated_(); 274 } 275 276 forceUpdateForTesting() { 277 this.update_(); 278 } 279 280 private setRestartRequired_(): void { 281 this.dataset['needsRestart'] = 'true'; 282 } 283 284 private filterUpdated_(): void { 285 this.el('filter-status').replaceChildren(); 286 localStorage.setItem('filter', this.filterInputElement.value); 287 288 this.proxy_.lookupTrialOrGroupName(this.filterInputElement.value) 289 .then(names => { 290 if (this.unhasher.add(names)) { 291 this.setDirty_(); 292 } 293 }); 294 this.setDirty_(); 295 } 296 297 private setDirty_() { 298 if (this.dirty) { 299 return; 300 } 301 this.dirty = true; 302 window.setTimeout(() => this.update_(), 500); 303 } 304 305 private update_() { 306 if (!this.dirty) { 307 return; 308 } 309 this.dirty = false; 310 for (const trial of this.trials) { 311 trial.update(); 312 } 313 this.filterToInput_(); 314 this.onUpdateForTesting(); 315 } 316 317 get filterInputElement(): HTMLInputElement { 318 return this.el('filter'); 319 } 320 321 private findTrialRow(trial: Trial): TrialRow|undefined { 322 for (const t of this.trials) { 323 if (t.trial.hash === trial.hash) { 324 return t; 325 } 326 } 327 return undefined; 328 } 329 330 toggleForceEnable(trial: Trial, group: Group) { 331 group.forceEnabled = !group.forceEnabled; 332 const trialRow = this.findTrialRow(trial); 333 if (trialRow) { 334 for (const row of trialRow.experimentRows) { 335 row.setForceEnabled( 336 group.forceEnabled && row.group.hash === group.hash); 337 } 338 } 339 340 this.proxy_.setTrialEnrollState(trial.hash, group.hash, group.forceEnabled); 341 this.setRestartRequired_(); 342 } 343 344 private populateState_(state: FieldTrialState) { 345 const trialListDiv = this.el('field-trial-list'); 346 this.trials = state.trials.map(t => new TrialRow(this, t)); 347 this.trials.sort((a, b) => a.sortKey().localeCompare(b.sortKey())); 348 trialListDiv.replaceChildren(...this.trials.map(t => t.root)); 349 this.dirty = true; 350 if (state.restartRequired) { 351 this.setRestartRequired_(); 352 } 353 this.update_(); 354 } 355 356 private filterToInput_(): void { 357 this.filter_(this.filterInputElement.value); 358 } 359 360 private filter_(searchText: string): void { 361 const searchFilter = new SearchFilter(this.unhasher, searchText); 362 let matchGroupCount = 0; 363 let matchTrialCount = 0; 364 let totalExperimentCount = 0; 365 for (const trial of this.trials) { 366 const [matched, matchedGroups] = trial.filter(searchFilter); 367 if (matched) { 368 ++matchTrialCount; 369 } 370 matchGroupCount += matchedGroups; 371 totalExperimentCount += trial.experimentRows.length; 372 } 373 // Expand all if the search term matches fewer than half of all experiment 374 // groups. 375 this.el('field-trial-list').dataset['expandAll'] = String( 376 matchGroupCount > 0 && matchGroupCount < totalExperimentCount / 2); 377 this.el('filter-status') 378 .replaceChildren( 379 ` (matched ${matchTrialCount} trials, ${matchGroupCount} groups)`); 380 } 381} 382 383declare global { 384 interface HTMLElementTagNameMap { 385 'field-trials-app': FieldTrialsAppElement; 386 } 387} 388 389customElements.define(FieldTrialsAppElement.is, FieldTrialsAppElement); 390