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 {NgTemplateOutlet} from '@angular/common'; 18import {Component, ElementRef, Inject, Input, ViewChild} from '@angular/core'; 19import {FormControl, ValidationErrors, Validators} from '@angular/forms'; 20import {assertDefined} from 'common/assert_utils'; 21import {TimeDuration} from 'common/time_duration'; 22import {TIME_UNIT_TO_NANO} from 'common/time_units'; 23import {Analytics} from 'logging/analytics'; 24import {TraceType} from 'trace/trace_type'; 25import {CollapsibleSections} from 'viewers/common/collapsible_sections'; 26import {CollapsibleSectionType} from 'viewers/common/collapsible_section_type'; 27import { 28 DeleteSavedQueryClickDetail, 29 QueryClickDetail, 30 SaveQueryClickDetail, 31 ViewerEvents, 32} from 'viewers/common/viewer_events'; 33import {timeButtonStyle} from 'viewers/components/styles/clickable_property.styles'; 34import {logComponentStyles} from 'viewers/components/styles/log_component.styles'; 35import { 36 viewerCardInnerStyle, 37 viewerCardStyle, 38} from 'viewers/components/styles/viewer_card.styles'; 39import {MenuOption} from './search_list_component'; 40import {Search, UiData} from './ui_data'; 41 42@Component({ 43 selector: 'viewer-search', 44 template: ` 45 <div class="card-grid" *ngIf="inputData"> 46 <collapsed-sections 47 [class.empty]="sections.areAllSectionsExpanded()" 48 [sections]="sections" 49 (sectionChange)="sections.onCollapseStateChange($event, false)"> 50 </collapsed-sections> 51 52 <div 53 class="global-search" 54 [class.collapsed]="sections.isSectionCollapsed(CollapsibleSectionType.GLOBAL_SEARCH)" 55 (click)="onGlobalSearchClick($event)"> 56 <div class="title-section"> 57 <collapsible-section-title 58 class="padded-title" 59 [title]="CollapsibleSectionType.GLOBAL_SEARCH" 60 (collapseButtonClicked)="sections.onCollapseStateChange(CollapsibleSectionType.GLOBAL_SEARCH, true)"></collapsible-section-title> 61 <span class="mat-body-2 message-with-spinner" *ngIf="initializing"> 62 <span>Initializing</span> 63 <mat-spinner [diameter]="20"></mat-spinner> 64 </span> 65 </div> 66 67 <mat-tab-group class="search-tabs"> 68 <mat-tab label="Search"> 69 <div class="body"> 70 <span class="mat-body-2"> 71 {{globalSearchText}} 72 </span> 73 74 <mat-form-field appearance="outline" class="query-field padded-field"> 75 <textarea matInput [formControl]="searchQueryControl" (keydown)="onTextAreaKeydown($event)" [readonly]="runningQuery"></textarea> 76 <mat-error *ngIf="searchQueryControl.invalid && searchQueryControl.value">Enter valid SQL query.</mat-error> 77 </mat-form-field> 78 79 <div class="query-actions"> 80 <div *ngIf="runningQuery" class="running-query-message"> 81 <mat-icon class="material-symbols-outlined"> timer </mat-icon> 82 <span class="mat-body-2 message-with-spinner"> 83 <span>Calculating results </span> 84 <mat-spinner [diameter]="20"></mat-spinner> 85 </span> 86 </div> 87 <div *ngIf="lastQueryExecutionTime" class="query-execution-time"> 88 <span class="mat-body-1"> 89 Executed in {{lastQueryExecutionTime}} 90 </span> 91 </div> 92 <button 93 mat-flat-button 94 class="query-button" 95 color="primary" 96 (click)="onSearchQueryClick()" 97 [disabled]="searchQueryDisabled()"> Run Search Query </button> 98 </div> 99 <div class="current-search" *ngFor="let search of inputData.currentSearches"> 100 <span class="query"> 101 <span class="mat-body-2"> Current: </span> 102 <span class="mat-body-1"> {{search.query}} </span> 103 </span> 104 <ng-container 105 [ngTemplateOutlet]="saveQueryField" 106 [ngTemplateOutletContext]="{search}"></ng-container> 107 </div> 108 </div> 109 </mat-tab> 110 111 <mat-tab label="Saved"> 112 <search-list 113 class="body" 114 [searches]="inputData.savedSearches" 115 placeholderText="Saved queries will appear here." 116 [menuOptions]="savedSearchMenuOptions"></search-list> 117 </mat-tab> 118 119 <mat-tab label="Recent"> 120 <search-list 121 class="body" 122 [searches]="inputData.recentSearches" 123 placeholderText="Recent queries will appear here." 124 [menuOptions]="recentSearchMenuOptions"></search-list> 125 </mat-tab> 126 127 <ng-template #saveQueryField let-search="search"> 128 <div class="outline-field save-field"> 129 <mat-form-field appearance="outline"> 130 <input matInput [formControl]="saveQueryNameControl" (keydown.enter)="onSaveQueryClick(search.query)"/> 131 <mat-error *ngIf="saveQueryNameControl.invalid && saveQueryNameControl.value">Query with that name already exists.</mat-error> 132 </mat-form-field> 133 <button 134 mat-flat-button 135 class="query-button" 136 color="primary" 137 [disabled]="saveQueryNameControl.invalid" 138 (click)="onSaveQueryClick(search.query)"> Save Query </button> 139 </div> 140 </ng-template> 141 </mat-tab-group> 142 </div> 143 144 <div 145 class="search-results" 146 [class.collapsed]="sections.isSectionCollapsed(CollapsibleSectionType.SEARCH_RESULTS)"> 147 <div class="title-section"> 148 <collapsible-section-title 149 class="padded-title" 150 [title]="CollapsibleSectionType.SEARCH_RESULTS" 151 (collapseButtonClicked)="sections.onCollapseStateChange(CollapsibleSectionType.SEARCH_RESULTS, true)"></collapsible-section-title> 152 </div> 153 <div class="result" *ngFor="let search of inputData.currentSearches"> 154 <div class="results-table"> 155 <log-view 156 class="results-log-view" 157 [entries]="search.entries" 158 [headers]="search.headers" 159 [selectedIndex]="search.selectedIndex" 160 [scrollToIndex]="search.scrollToIndex" 161 [currentIndex]="search.currentIndex" 162 [traceType]="${TraceType.SEARCH}" 163 [showTraceEntryTimes]="false" 164 [showCurrentTimeButton]="false"></log-view> 165 </div> 166 </div> 167 </div> 168 169 <div 170 class="how-to-search" 171 [class.collapsed]="sections.isSectionCollapsed(CollapsibleSectionType.HOW_TO_SEARCH)"> 172 <div class="title-section"> 173 <collapsible-section-title 174 class="padded-title" 175 [title]="CollapsibleSectionType.HOW_TO_SEARCH" 176 (collapseButtonClicked)="sections.onCollapseStateChange(CollapsibleSectionType.HOW_TO_SEARCH, true)"></collapsible-section-title> 177 </div> 178 </div> 179 </div> 180 `, 181 styles: [ 182 ` 183 .search-tabs { 184 height: 100%; 185 } 186 .global-search .body { 187 display: flex; 188 flex-direction: column; 189 } 190 .query-field { 191 height: fit-content; 192 } 193 .query-field textarea { 194 height: 300px; 195 } 196 .query-button { 197 width: fit-content; 198 line-height: 24px; 199 padding: 0 10px; 200 } 201 .end-align-button { 202 align-self: end; 203 } 204 .query-actions { 205 display: flex; 206 flex-direction: row; 207 justify-content: end; 208 column-gap: 10px; 209 align-items: center; 210 } 211 .running-query-message { 212 display: flex; 213 flex-direction: row; 214 align-items: center; 215 color: #FF8A00; 216 } 217 .current-search { 218 padding: 10px 0px; 219 } 220 .current-search .query { 221 display: flex; 222 flex-direction: column; 223 } 224 .message-with-spinner { 225 display: flex; 226 flex-direction: row; 227 align-items: center; 228 justify-content: space-between; 229 } 230 231 .result, .results-table { 232 height: 100%; 233 display: flex; 234 flex-direction: column; 235 } 236 .results-log-view { 237 display: flex; 238 flex-direction: column; 239 overflow: auto; 240 border-radius: 4px; 241 background-color: var(--background-color); 242 flex: 1; 243 } 244 `, 245 viewerCardStyle, 246 viewerCardInnerStyle, 247 logComponentStyles, 248 timeButtonStyle, 249 ], 250}) 251export class ViewerSearchComponent { 252 @Input() inputData: UiData | undefined; 253 @ViewChild('saveQueryField') saveQueryField: NgTemplateOutlet | undefined; 254 255 CollapsibleSectionType = CollapsibleSectionType; 256 sections = new CollapsibleSections([ 257 { 258 type: CollapsibleSectionType.GLOBAL_SEARCH, 259 label: CollapsibleSectionType.GLOBAL_SEARCH, 260 isCollapsed: false, 261 }, 262 { 263 type: CollapsibleSectionType.SEARCH_RESULTS, 264 label: CollapsibleSectionType.SEARCH_RESULTS, 265 isCollapsed: false, 266 }, 267 { 268 type: CollapsibleSectionType.HOW_TO_SEARCH, 269 label: CollapsibleSectionType.HOW_TO_SEARCH, 270 isCollapsed: false, 271 }, 272 ]); 273 searchQueryControl = new FormControl('', Validators.required); 274 saveQueryNameControl = new FormControl( 275 '', 276 assertDefined( 277 Validators.compose([ 278 Validators.required, 279 (control: FormControl) => 280 this.validateSearchQuerySaveName( 281 control, 282 this.inputData?.savedSearches ?? [], 283 ), 284 ]), 285 ), 286 ); 287 runningQuery: string | undefined; 288 lastQueryExecutionTime: string | undefined; 289 lastQueryStartTime: number | undefined; 290 initializing = false; 291 readonly savedSearchMenuOptions: MenuOption[] = [ 292 { 293 name: 'Run Query', 294 onClickCallback: (search: Search) => { 295 Analytics.TraceSearch.logQueryRequested('saved'); 296 this.onRunQueryFromOptionsClick(search); 297 }, 298 }, 299 { 300 name: 'Delete Query', 301 onClickCallback: (search: Search) => this.onDeleteQueryClick(search), 302 }, 303 ]; 304 readonly recentSearchMenuOptions: MenuOption[] = [ 305 { 306 name: 'Run Query', 307 onClickCallback: (search: Search) => { 308 Analytics.TraceSearch.logQueryRequested('recent'); 309 this.onRunQueryFromOptionsClick(search); 310 }, 311 }, 312 {name: 'Save Query', onClickCallback: (search: Search) => {}}, 313 ]; 314 315 readonly globalSearchText = ` 316 Write an SQL query in the field below, and run the search. \ 317 Results will be shown in a tabular view and you can optionally visualize them in the timeline. \ 318 `; 319 320 constructor( 321 @Inject(ElementRef) private elementRef: ElementRef<HTMLElement>, 322 ) {} 323 324 ngAfterViewInit() { 325 this.recentSearchMenuOptions[1].innerMenu = this.saveQueryField; 326 } 327 328 ngOnChanges() { 329 if (this.initializing && this.inputData?.initialized) { 330 this.initializing = false; 331 } 332 const runningQueryComplete = this.inputData?.currentSearches.some( 333 (search) => search.query === this.runningQuery, 334 ); 335 if ( 336 this.runningQuery && 337 (runningQueryComplete || this.inputData?.lastTraceFailed) 338 ) { 339 if (runningQueryComplete) { 340 this.searchQueryControl.setValue(this.runningQuery); 341 this.saveQueryNameControl.setValue(this.runningQuery); 342 } 343 const executionTimeMs = 344 Date.now() - assertDefined(this.lastQueryStartTime); 345 Analytics.TraceSearch.logQueryExecutionTime(executionTimeMs); 346 this.lastQueryExecutionTime = new TimeDuration( 347 BigInt(executionTimeMs * TIME_UNIT_TO_NANO.ms), 348 ).format(); 349 this.lastQueryStartTime = undefined; 350 this.runningQuery = undefined; 351 } 352 } 353 354 onGlobalSearchClick() { 355 if (!this.initializing && !this.inputData?.initialized) { 356 this.initializing = true; 357 const event = new CustomEvent(ViewerEvents.GlobalSearchSectionClick); 358 this.elementRef.nativeElement.dispatchEvent(event); 359 } 360 } 361 362 onSearchQueryClick() { 363 this.runningQuery = assertDefined(this.searchQueryControl.value); 364 Analytics.TraceSearch.logQueryRequested('new'); 365 this.dispatchSearchQueryEvent(); 366 } 367 368 onSaveQueryClick(query: string) { 369 if (this.saveQueryNameControl.invalid) { 370 return; 371 } 372 const event = new CustomEvent(ViewerEvents.SaveQueryClick, { 373 detail: new SaveQueryClickDetail( 374 query, 375 assertDefined(this.saveQueryNameControl.value), 376 ), 377 }); 378 this.elementRef.nativeElement.dispatchEvent(event); 379 Analytics.TraceSearch.logQuerySaved(); 380 this.saveQueryNameControl.reset(); 381 } 382 383 onRunQueryFromOptionsClick(search: Search) { 384 this.runningQuery = search.query; 385 this.dispatchSearchQueryEvent(); 386 } 387 388 onDeleteQueryClick(search: Search) { 389 const event = new CustomEvent(ViewerEvents.DeleteSavedQueryClick, { 390 detail: new DeleteSavedQueryClickDetail(search), 391 }); 392 this.elementRef.nativeElement.dispatchEvent(event); 393 } 394 395 searchQueryDisabled(): boolean { 396 return ( 397 this.searchQueryControl.invalid || 398 !!this.runningQuery || 399 !this.inputData?.initialized 400 ); 401 } 402 403 currentSearchPresent(): boolean { 404 return (this.inputData?.currentSearches.length ?? 0) > 0; 405 } 406 407 onTextAreaKeydown(event: KeyboardEvent) { 408 event.stopPropagation(); 409 if ( 410 event.key === 'Enter' && 411 !event.shiftKey && 412 !this.searchQueryDisabled() 413 ) { 414 event.preventDefault(); 415 this.onSearchQueryClick(); 416 } 417 } 418 419 private validateSearchQuerySaveName( 420 control: FormControl, 421 savedSearches: Search[], 422 ): ValidationErrors | null { 423 const valid = 424 control.value && 425 !savedSearches.some((search) => search.name === control.value); 426 return !valid ? {invalidInput: control.value} : null; 427 } 428 429 private dispatchSearchQueryEvent() { 430 this.lastQueryExecutionTime = undefined; 431 this.lastQueryStartTime = Date.now(); 432 const event = new CustomEvent(ViewerEvents.SearchQueryClick, { 433 detail: new QueryClickDetail(assertDefined(this.runningQuery)), 434 }); 435 this.elementRef.nativeElement.dispatchEvent(event); 436 } 437} 438