1/* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import {CdkAccordionItem} from '@angular/cdk/accordion'; 18import {NgTemplateOutlet} from '@angular/common'; 19import { 20 Component, 21 ElementRef, 22 Inject, 23 QueryList, 24 SimpleChanges, 25 ViewChild, 26 ViewChildren, 27} from '@angular/core'; 28import {FormControl, ValidationErrors, Validators} from '@angular/forms'; 29import {MatTabGroup} from '@angular/material/tabs'; 30import {SEARCH_VIEWS} from 'app/trace_search/trace_search_initializer'; 31import {assertDefined} from 'common/assert_utils'; 32import {TimeDuration} from 'common/time/time_duration'; 33import {TIME_UNIT_TO_NANO} from 'common/time/time_units'; 34import {Analytics} from 'logging/analytics'; 35import {TraceType} from 'trace/trace_type'; 36import {CollapsibleSections} from 'viewers/common/collapsible_sections'; 37import {CollapsibleSectionType} from 'viewers/common/collapsible_section_type'; 38import { 39 AddQueryClickDetail, 40 ClearQueryClickDetail, 41 DeleteSavedQueryClickDetail, 42 SaveQueryClickDetail, 43 SearchQueryClickDetail, 44 ViewerEvents, 45} from 'viewers/common/viewer_events'; 46import { 47 viewerCardInnerStyle, 48 viewerCardStyle, 49} from 'viewers/components/styles/viewer_card.styles'; 50import {ViewerComponent} from 'viewers/components/viewer_component'; 51import {ActiveSearchComponent} from './active_search_component'; 52import {ListItemOption} from './search_list_component'; 53import {CurrentSearch, ListedSearch, UiData} from './ui_data'; 54 55@Component({ 56 selector: 'viewer-search', 57 template: ` 58 <div class="card-grid" *ngIf="inputData"> 59 <collapsed-sections 60 [class.empty]="sections.areAllSectionsExpanded()" 61 [sections]="sections" 62 (sectionChange)="sections.onCollapseStateChange($event, false)"> 63 </collapsed-sections> 64 65 <div 66 class="global-search" 67 [class.collapsed]="sections.isSectionCollapsed(CollapsibleSectionType.GLOBAL_SEARCH)" 68 (click)="onGlobalSearchClick($event)"> 69 <div class="title-section"> 70 <collapsible-section-title 71 class="padded-title" 72 [title]="CollapsibleSectionType.GLOBAL_SEARCH" 73 (collapseButtonClicked)="sections.onCollapseStateChange(CollapsibleSectionType.GLOBAL_SEARCH, true)"></collapsible-section-title> 74 <span class="mat-body-2 message-with-spinner" *ngIf="initializing"> 75 <span>Initializing</span> 76 <mat-spinner [diameter]="20"></mat-spinner> 77 </span> 78 </div> 79 80 <mat-tab-group class="search-tabs" (animationDone)="onSearchTabChanged()"> 81 <mat-tab label="Search"> 82 <div class="body"> 83 <span class="mat-body-2"> 84 {{globalSearchText}} 85 </span> 86 87 <ng-container *ngFor="let section of searchSections; let i = index"> 88 <mat-divider *ngIf="i > 0" class="section-divider"></mat-divider> 89 <active-search 90 [canClear]="searchSections.length > 1" 91 [isSearchInitialized]="inputData.initialized" 92 [executedQuery]="getExecutedQueryForSearchSection(section.uid)" 93 [saveQueryField]="saveQueryField" 94 [lastTraceFailed]="inputData.lastTraceFailed ?? false" 95 [canAdd]="i === searchSections.length - 1" 96 [label]="getQueryLabel(section.uid)" 97 [saveQueryNameControl]="section.saveQueryNameControl" 98 [lastQueryExecutionTime]="section.lastQueryExecutionTime" 99 [runningQuery]="runningQueryUid === section.uid" 100 (clearQueryClick)="clearQuery(section.uid)" 101 (searchQueryClick)="searchQuery($event, section.uid)" 102 (addQueryClick)="addQuery()"></active-search> 103 </ng-container> 104 </div> 105 </mat-tab> 106 107 <mat-tab label="Saved"> 108 <search-list 109 class="body" 110 [searches]="inputData.savedSearches" 111 placeholderText="Saved queries will appear here." 112 [listItemOptions]="savedSearchOptions"></search-list> 113 </mat-tab> 114 115 <mat-tab label="Recent"> 116 <search-list 117 class="body" 118 [searches]="inputData.recentSearches" 119 placeholderText="Recent queries will appear here." 120 [listItemOptions]="recentSearchOptions" 121 [control]="menuSaveQueryNameControl"></search-list> 122 </mat-tab> 123 124 <ng-template #saveQueryField let-query="query" let-control="control"> 125 <div class="outline-field save-field"> 126 <mat-form-field appearance="outline"> 127 <input matInput [formControl]="control" (keydown.enter)="onSaveQueryClick(query, control)"/> 128 <mat-error *ngIf="control.invalid && control.value">Query with that name already exists.</mat-error> 129 </mat-form-field> 130 <button 131 mat-flat-button 132 class="query-button" 133 color="primary" 134 [disabled]="control.invalid" 135 (click)="onSaveQueryClick(query, control)"> Save </button> 136 </div> 137 </ng-template> 138 </mat-tab-group> 139 </div> 140 141 <div 142 class="search-results" 143 [class.collapsed]="sections.isSectionCollapsed(CollapsibleSectionType.SEARCH_RESULTS)"> 144 <div class="title-section"> 145 <collapsible-section-title 146 class="padded-title" 147 [title]="CollapsibleSectionType.SEARCH_RESULTS" 148 (collapseButtonClicked)="sections.onCollapseStateChange(CollapsibleSectionType.SEARCH_RESULTS, true)"></collapsible-section-title> 149 </div> 150 <div class="results-placeholder placeholder-text mat-body-1" *ngIf="showResultsPlaceholder()"> Run a search to view tabulated results. </div> 151 <mat-tab-group class="result-tabs"> 152 <mat-tab *ngFor="let curr of getCurrentSearchesWithResults()" [label]="getQueryLabel(curr.uid)"> 153 <div class="result"> 154 <div class="results-table"> 155 <log-view 156 class="results-log-view" 157 [entries]="curr.result.entries" 158 [headers]="curr.result.headers" 159 [selectedIndex]="curr.result.selectedIndex" 160 [scrollToIndex]="curr.result.scrollToIndex" 161 [currentIndex]="curr.result.currentIndex" 162 [traceType]="${TraceType.SEARCH}" 163 [showTraceEntryTimes]="false" 164 [showCurrentTimeButton]="false" 165 [padEntries]="false" 166 [isFetchingData]="curr.result.isFetchingData"></log-view> 167 </div> 168 </div> 169 </mat-tab> 170 </mat-tab-group> 171 </div> 172 173 <div 174 class="how-to-search" 175 [class.collapsed]="sections.isSectionCollapsed(CollapsibleSectionType.HOW_TO_SEARCH)"> 176 <div class="title-section"> 177 <collapsible-section-title 178 class="padded-title" 179 [title]="CollapsibleSectionType.HOW_TO_SEARCH" 180 (collapseButtonClicked)="sections.onCollapseStateChange(CollapsibleSectionType.HOW_TO_SEARCH, true)"></collapsible-section-title> 181 </div> 182 183 <div class="body"> 184 <span class="mat-body-1"> 185 Run custom SQL queries on Perfetto traces. Use specialized SQL views to aid with searching: 186 </span> 187 188 <cdk-accordion class="how-to-accordion" [multi]="true"> 189 <cdk-accordion-item *ngFor="let searchView of SEARCH_VIEWS" class="accordion-item" #accordionItem="cdkAccordionItem"> 190 <span 191 class="mat-body-1 accordion-item-header" 192 (click)="onHeaderClick(accordionItem)"> 193 <mat-icon> 194 {{ accordionItem.expanded ? 'arrow_drop_down' : 'chevron_right' }} 195 </mat-icon> 196 <code>{{searchView.name}}</code> 197 </span> 198 <div *ngIf="accordionItem.expanded" class="accordion-item-body"> 199 <span class="mat-body-1"> 200 Use to search {{searchView.dataType}} data. 201 </span> 202 <span class="mat-body-2">Spec:</span> 203 <table> 204 <tr *ngFor="let column of searchView.columns"> 205 <td><code>{{column.name}}</code></td> 206 <td class="mat-body-1">{{column.desc}}</td> 207 </tr> 208 </table> 209 <span class="mat-body-2"> 210 Examples: 211 </span> 212 <ng-container *ngFor="let example of searchView.examples"> 213 <pre><code>{{example.query}}</code></pre> 214 <span class="mat-body-1 indented"><i>{{example.desc}}</i></span> 215 </ng-container> 216 </div> 217 </cdk-accordion-item> 218 </cdk-accordion> 219 </div> 220 </div> 221 </div> 222 `, 223 styles: [ 224 ` 225 .search-tabs, .result-tabs { 226 height: 100%; 227 } 228 .message-with-spinner { 229 display: flex; 230 flex-direction: row; 231 align-items: center; 232 justify-content: space-between; 233 } 234 .global-search .body { 235 display: flex; 236 flex-direction: column; 237 } 238 .section-divider { 239 margin-top: 18px; 240 } 241 active-search { 242 display: flex; 243 flex-direction: column; 244 margin-top: 12px; 245 } 246 247 .result, .results-table { 248 height: 100%; 249 display: flex; 250 flex-direction: column; 251 } 252 .results-log-view { 253 display: flex; 254 flex-direction: column; 255 overflow: auto; 256 border-radius: 4px; 257 background-color: var(--background-color); 258 flex: 1; 259 } 260 261 .how-to-search .body { 262 display: flex; 263 flex-direction: column; 264 padding: 12px; 265 } 266 .how-to-search .how-to-accordion { 267 display: flex; 268 flex-direction: column; 269 min-width: fit-content; 270 } 271 .how-to-search .accordion-item { 272 border: 1px solid var(--border-color); 273 } 274 .how-to-search .accordion-item + .accordion-item { 275 border-top: none; 276 } 277 .how-to-search .accordion-item:first-child { 278 border-top-left-radius: 4px; 279 border-top-right-radius: 4px; 280 } 281 .how-to-search .accordion-item:last-child { 282 border-bottom-left-radius: 4px; 283 border-bottom-right-radius: 4px; 284 } 285 .how-to-search .accordion-item-header { 286 width: 100%; 287 display: flex; 288 flex-direction: row; 289 align-items: center; 290 cursor: pointer; 291 } 292 .how-to-search .accordion-item-body { 293 padding: 8px; 294 display: flex; 295 flex-direction: column; 296 } 297 .how-to-search table { 298 border-spacing: 0; 299 } 300 .how-to-search table td { 301 border-left: 1px solid var(--border-color); 302 border-top: 1px solid var(--border-color); 303 padding-left: 4px; 304 padding-right: 4px; 305 } 306 .how-to-search table tr:first-child td:first-child { 307 border-top-left-radius: 4px; 308 } 309 .how-to-search table tr:first-child td:last-child { 310 border-top-right-radius: 4px; 311 } 312 .how-to-search table tr:last-child td:first-child { 313 border-bottom-left-radius: 4px; 314 } 315 .how-to-search table tr:last-child td:last-child { 316 border-bottom-right-radius: 4px; 317 } 318 .how-to-search table tr:last-child td { 319 border-bottom: 1px solid var(--border-color); 320 } 321 .how-to-search table tr td:last-child { 322 border-right: 1px solid var(--border-color); 323 } 324 .how-to-search .body .indented { 325 margin-inline-start: 5px; 326 } 327 .how-to-search code { 328 font-size: 12px; 329 } 330 .how-to-search pre { 331 white-space: pre-wrap; 332 word-break: break-word; 333 border-radius: 4px; 334 padding: 0px 4px; 335 margin: 0; 336 margin-block: 5px; 337 background: var(--drawer-block-primary); 338 } 339 `, 340 viewerCardStyle, 341 viewerCardInnerStyle, 342 ], 343}) 344export class ViewerSearchComponent extends ViewerComponent<UiData> { 345 @ViewChild('saveQueryField') saveQueryField: NgTemplateOutlet | undefined; 346 @ViewChildren(MatTabGroup) matTabGroups: QueryList<MatTabGroup> | undefined; 347 @ViewChildren(ActiveSearchComponent) activeSearchComponents: 348 | QueryList<ActiveSearchComponent> 349 | undefined; 350 351 CollapsibleSectionType = CollapsibleSectionType; 352 sections = new CollapsibleSections([ 353 { 354 type: CollapsibleSectionType.GLOBAL_SEARCH, 355 label: CollapsibleSectionType.GLOBAL_SEARCH, 356 isCollapsed: false, 357 }, 358 { 359 type: CollapsibleSectionType.SEARCH_RESULTS, 360 label: CollapsibleSectionType.SEARCH_RESULTS, 361 isCollapsed: false, 362 }, 363 { 364 type: CollapsibleSectionType.HOW_TO_SEARCH, 365 label: CollapsibleSectionType.HOW_TO_SEARCH, 366 isCollapsed: false, 367 }, 368 ]); 369 searchSections: SearchSection[] = []; 370 initializing = false; 371 menuSaveQueryNameControl = this.makeSaveQueryNameControl(); 372 runningQueryUid: number | undefined; 373 374 private runFromOptions = false; 375 private editFromOptions = false; 376 private readonly editOption: ListItemOption = { 377 name: 'Edit', 378 icon: 'edit', 379 onClickCallback: (search: ListedSearch) => { 380 this.onEditQueryClick(search); 381 }, 382 }; 383 private readonly saveOption: ListItemOption = { 384 name: 'Save', 385 icon: 'save', 386 }; 387 readonly savedSearchOptions: ListItemOption[] = [ 388 { 389 name: 'Run', 390 icon: 'play_arrow', 391 onClickCallback: (search: ListedSearch) => { 392 Analytics.TraceSearch.logQueryRequested('saved'); 393 this.onRunQueryFromOptionsClick(search); 394 }, 395 }, 396 this.editOption, 397 { 398 name: 'Delete', 399 icon: 'delete', 400 onClickCallback: (search: ListedSearch) => { 401 this.onDeleteQueryClick(search); 402 }, 403 }, 404 ]; 405 readonly recentSearchOptions: ListItemOption[] = [ 406 { 407 name: 'Run', 408 icon: 'play_arrow', 409 onClickCallback: (search: ListedSearch) => { 410 Analytics.TraceSearch.logQueryRequested('recent'); 411 this.onRunQueryFromOptionsClick(search); 412 }, 413 }, 414 this.editOption, 415 this.saveOption, 416 ]; 417 readonly globalSearchText = ` 418 Write an SQL query in the field below, and run the search. \ 419 Results will be shown in a tabular view and you can optionally visualize them in the timeline. \ 420 `; 421 readonly SEARCH_VIEWS = SEARCH_VIEWS; 422 423 constructor(@Inject(ElementRef) private elementRef: ElementRef<HTMLElement>) { 424 super(); 425 } 426 427 ngAfterViewInit() { 428 this.saveOption.menu = this.saveQueryField; 429 } 430 431 ngOnChanges(simpleChanges: SimpleChanges) { 432 if (this.initializing && this.inputData?.initialized) { 433 this.initializing = false; 434 } 435 this.updateSearchSections(simpleChanges); 436 if (this.tryPropagateRunFromOptions()) { 437 return; 438 } 439 this.tryHandleQueryCompleted(); 440 } 441 442 ngAfterContentChecked() { 443 this.tryPropagateEditFromOptions(); 444 } 445 446 onGlobalSearchClick() { 447 if (!this.initializing && !this.inputData?.initialized) { 448 this.initializing = true; 449 const event = new CustomEvent(ViewerEvents.GlobalSearchSectionClick); 450 this.elementRef.nativeElement.dispatchEvent(event); 451 } 452 } 453 454 searchQuery(query: string, uid: number) { 455 this.runningQueryUid = uid; 456 const section = assertDefined( 457 this.searchSections.find((s) => s.uid === uid), 458 ); 459 section.lastQueryExecutionTime = undefined; 460 section.lastQueryStartTime = Date.now(); 461 const event = new CustomEvent(ViewerEvents.SearchQueryClick, { 462 detail: new SearchQueryClickDetail(query, uid), 463 }); 464 this.elementRef.nativeElement.dispatchEvent(event); 465 } 466 467 onSaveQueryClick(query: string, control: FormControl) { 468 if (control.invalid) { 469 return; 470 } 471 const event = new CustomEvent(ViewerEvents.SaveQueryClick, { 472 detail: new SaveQueryClickDetail(query, assertDefined(control.value)), 473 }); 474 this.elementRef.nativeElement.dispatchEvent(event); 475 Analytics.TraceSearch.logQuerySaved(); 476 control.reset(); 477 } 478 479 onHeaderClick(accordionItem: CdkAccordionItem) { 480 accordionItem.toggle(); 481 } 482 483 clearQuery(uid: number) { 484 const event = new CustomEvent(ViewerEvents.ClearQueryClick, { 485 detail: new ClearQueryClickDetail(uid), 486 }); 487 this.elementRef.nativeElement.dispatchEvent(event); 488 } 489 490 addQuery(query?: string) { 491 const event = new CustomEvent(ViewerEvents.AddQueryClick, { 492 detail: query ? new AddQueryClickDetail(query) : undefined, 493 }); 494 this.elementRef.nativeElement.dispatchEvent(event); 495 } 496 497 getCurrentSearchesWithResults(): CurrentSearch[] { 498 return assertDefined(this.inputData).currentSearches.filter( 499 (search) => search.result !== undefined, 500 ); 501 } 502 503 getCurrentSearchByUid(uid: number): CurrentSearch | undefined { 504 return this.inputData?.currentSearches.find((search) => search.uid === uid); 505 } 506 507 getExecutedQueryForSearchSection(uid: number): string | undefined { 508 return this.runningQueryUid !== uid 509 ? this.getCurrentSearchByUid(uid)?.query 510 : undefined; 511 } 512 513 getQueryLabel(uid: number): string { 514 return 'Query ' + uid; 515 } 516 517 showResultsPlaceholder(): boolean { 518 return ( 519 this.runningQueryUid === undefined && 520 this.getCurrentSearchesWithResults().length === 0 521 ); 522 } 523 524 onSearchTabChanged() { 525 const finalComponent = assertDefined(this.activeSearchComponents).last; 526 if (assertDefined(this.matTabGroups).first.selectedIndex === 0) { 527 finalComponent.elementRef.nativeElement.scrollIntoView(); 528 } 529 } 530 531 private updateSearchSections(simpleChanges: SimpleChanges) { 532 const currentSearches = this.inputData?.currentSearches; 533 const previousSearches: CurrentSearch[] | undefined = 534 simpleChanges['inputData']?.previousValue?.currentSearches; 535 currentSearches?.forEach((search) => { 536 if (!this.searchSections.some((s) => s.uid === search.uid)) { 537 this.searchSections.push({ 538 uid: search.uid, 539 saveQueryNameControl: this.makeSaveQueryNameControl(), 540 }); 541 } 542 }); 543 previousSearches?.forEach((search) => { 544 if (!currentSearches?.some((curr) => curr.uid === search.uid)) { 545 const i = this.searchSections.findIndex((a) => a.uid === search.uid); 546 this.searchSections.splice(i, 1); 547 } 548 }); 549 } 550 551 private tryPropagateRunFromOptions(): boolean { 552 if ( 553 this.runFromOptions && 554 this.runningQueryUid === undefined && 555 this.inputData?.currentSearches 556 ) { 557 const lastSearch = 558 this.inputData.currentSearches[ 559 this.inputData.currentSearches.length - 1 560 ]; 561 this.searchQuery(assertDefined(lastSearch.query), lastSearch.uid); 562 this.runFromOptions = false; 563 return true; 564 } 565 return false; 566 } 567 568 private tryPropagateEditFromOptions() { 569 if (this.editFromOptions) { 570 const currentSearches = assertDefined(this.inputData).currentSearches; 571 if (currentSearches.length !== this.activeSearchComponents?.length) { 572 return; 573 } 574 const lastSearch = currentSearches[currentSearches.length - 1]; 575 if (lastSearch.query) { 576 this.updateLastSectionTextAndShowTab(lastSearch.query); 577 this.editFromOptions = false; 578 } 579 } 580 } 581 582 private updateLastSectionTextAndShowTab(text: string) { 583 assertDefined( 584 this.activeSearchComponents?.get(this.searchSections.length - 1), 585 ).updateText(text); 586 assertDefined(this.matTabGroups).first.selectedIndex = 0; 587 } 588 589 private tryHandleQueryCompleted() { 590 const currentSearch = 591 this.runningQueryUid !== undefined 592 ? this.getCurrentSearchByUid(this.runningQueryUid) 593 : undefined; 594 595 if (this.runningQueryUid !== undefined && currentSearch !== undefined) { 596 const sectionIndex = this.searchSections.findIndex( 597 (s) => s.uid === this.runningQueryUid, 598 ); 599 const section = this.searchSections[sectionIndex]; 600 601 if (!this.inputData?.lastTraceFailed) { 602 this.activeSearchComponents 603 ?.get(sectionIndex) 604 ?.updateText(currentSearch?.query ?? ''); 605 section.saveQueryNameControl.setValue( 606 this.getQueryLabel(assertDefined(this.runningQueryUid)), 607 ); 608 assertDefined(this.matTabGroups).last.selectedIndex = sectionIndex; 609 } 610 611 const executionTimeMs = 612 Date.now() - assertDefined(section.lastQueryStartTime); 613 Analytics.TraceSearch.logQueryExecutionTime(executionTimeMs); 614 section.lastQueryExecutionTime = new TimeDuration( 615 BigInt(executionTimeMs * TIME_UNIT_TO_NANO.ms), 616 ).format(); 617 section.lastQueryStartTime = undefined; 618 619 this.runningQueryUid = undefined; 620 } 621 } 622 623 private onRunQueryFromOptionsClick(search: ListedSearch) { 624 const lastUid = this.getLastUid(); 625 if (this.getCurrentSearchByUid(lastUid)?.result) { 626 this.runFromOptions = true; 627 this.addQuery(search.query); 628 } else { 629 this.searchQuery(search.query, lastUid); 630 } 631 } 632 633 private getLastUid(): number { 634 return this.searchSections[this.searchSections.length - 1].uid; 635 } 636 637 private onEditQueryClick(search: ListedSearch) { 638 const currentSearches = assertDefined(this.inputData).currentSearches; 639 const lastCurrentSearch = currentSearches[currentSearches.length - 1]; 640 if (lastCurrentSearch.result !== undefined) { 641 this.editFromOptions = true; 642 this.addQuery(search.query); 643 return; 644 } 645 this.updateLastSectionTextAndShowTab(search.query); 646 } 647 648 private onDeleteQueryClick(search: ListedSearch) { 649 const event = new CustomEvent(ViewerEvents.DeleteSavedQueryClick, { 650 detail: new DeleteSavedQueryClickDetail(search), 651 }); 652 this.elementRef.nativeElement.dispatchEvent(event); 653 } 654 655 private makeSaveQueryNameControl() { 656 return new FormControl( 657 '', 658 assertDefined( 659 Validators.compose([ 660 Validators.required, 661 (control: FormControl) => 662 this.validateSearchQuerySaveName( 663 control, 664 this.inputData?.savedSearches ?? [], 665 ), 666 ]), 667 ), 668 ); 669 } 670 671 private validateSearchQuerySaveName( 672 control: FormControl, 673 savedSearches: ListedSearch[], 674 ): ValidationErrors | null { 675 const valid = 676 control.value && 677 !savedSearches.some((search) => search.name === control.value); 678 return !valid ? {invalidInput: control.value} : null; 679 } 680} 681 682interface SearchSection { 683 uid: number; 684 saveQueryNameControl: FormControl; 685 lastQueryExecutionTime?: string; 686 lastQueryStartTime?: number; 687} 688