/* * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {CdkAccordionItem} from '@angular/cdk/accordion'; import {NgTemplateOutlet} from '@angular/common'; import { Component, ElementRef, Inject, QueryList, SimpleChanges, ViewChild, ViewChildren, } from '@angular/core'; import {FormControl, ValidationErrors, Validators} from '@angular/forms'; import {MatTabGroup} from '@angular/material/tabs'; import {SEARCH_VIEWS} from 'app/trace_search/trace_search_initializer'; import {assertDefined} from 'common/assert_utils'; import {TimeDuration} from 'common/time/time_duration'; import {TIME_UNIT_TO_NANO} from 'common/time/time_units'; import {Analytics} from 'logging/analytics'; import {TraceType} from 'trace/trace_type'; import {CollapsibleSections} from 'viewers/common/collapsible_sections'; import {CollapsibleSectionType} from 'viewers/common/collapsible_section_type'; import { AddQueryClickDetail, ClearQueryClickDetail, DeleteSavedQueryClickDetail, SaveQueryClickDetail, SearchQueryClickDetail, ViewerEvents, } from 'viewers/common/viewer_events'; import { viewerCardInnerStyle, viewerCardStyle, } from 'viewers/components/styles/viewer_card.styles'; import {ViewerComponent} from 'viewers/components/viewer_component'; import {ActiveSearchComponent} from './active_search_component'; import {ListItemOption} from './search_list_component'; import {CurrentSearch, ListedSearch, UiData} from './ui_data'; @Component({ selector: 'viewer-search', template: `
Run a search to view tabulated results.
`, styles: [ ` .search-tabs, .result-tabs { height: 100%; } .message-with-spinner { display: flex; flex-direction: row; align-items: center; justify-content: space-between; } .global-search .body { display: flex; flex-direction: column; } .section-divider { margin-top: 18px; } active-search { display: flex; flex-direction: column; margin-top: 12px; } .result, .results-table { height: 100%; display: flex; flex-direction: column; } .results-log-view { display: flex; flex-direction: column; overflow: auto; border-radius: 4px; background-color: var(--background-color); flex: 1; } .how-to-search .body { display: flex; flex-direction: column; padding: 12px; } .how-to-search .how-to-accordion { display: flex; flex-direction: column; min-width: fit-content; } .how-to-search .accordion-item { border: 1px solid var(--border-color); } .how-to-search .accordion-item + .accordion-item { border-top: none; } .how-to-search .accordion-item:first-child { border-top-left-radius: 4px; border-top-right-radius: 4px; } .how-to-search .accordion-item:last-child { border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; } .how-to-search .accordion-item-header { width: 100%; display: flex; flex-direction: row; align-items: center; cursor: pointer; } .how-to-search .accordion-item-body { padding: 8px; display: flex; flex-direction: column; } .how-to-search table { border-spacing: 0; } .how-to-search table td { border-left: 1px solid var(--border-color); border-top: 1px solid var(--border-color); padding-left: 4px; padding-right: 4px; } .how-to-search table tr:first-child td:first-child { border-top-left-radius: 4px; } .how-to-search table tr:first-child td:last-child { border-top-right-radius: 4px; } .how-to-search table tr:last-child td:first-child { border-bottom-left-radius: 4px; } .how-to-search table tr:last-child td:last-child { border-bottom-right-radius: 4px; } .how-to-search table tr:last-child td { border-bottom: 1px solid var(--border-color); } .how-to-search table tr td:last-child { border-right: 1px solid var(--border-color); } .how-to-search .body .indented { margin-inline-start: 5px; } .how-to-search code { font-size: 12px; } .how-to-search pre { white-space: pre-wrap; word-break: break-word; border-radius: 4px; padding: 0px 4px; margin: 0; margin-block: 5px; background: var(--drawer-block-primary); } `, viewerCardStyle, viewerCardInnerStyle, ], }) export class ViewerSearchComponent extends ViewerComponent { @ViewChild('saveQueryField') saveQueryField: NgTemplateOutlet | undefined; @ViewChildren(MatTabGroup) matTabGroups: QueryList | undefined; @ViewChildren(ActiveSearchComponent) activeSearchComponents: | QueryList | undefined; CollapsibleSectionType = CollapsibleSectionType; sections = new CollapsibleSections([ { type: CollapsibleSectionType.GLOBAL_SEARCH, label: CollapsibleSectionType.GLOBAL_SEARCH, isCollapsed: false, }, { type: CollapsibleSectionType.SEARCH_RESULTS, label: CollapsibleSectionType.SEARCH_RESULTS, isCollapsed: false, }, { type: CollapsibleSectionType.HOW_TO_SEARCH, label: CollapsibleSectionType.HOW_TO_SEARCH, isCollapsed: false, }, ]); searchSections: SearchSection[] = []; initializing = false; menuSaveQueryNameControl = this.makeSaveQueryNameControl(); runningQueryUid: number | undefined; private runFromOptions = false; private editFromOptions = false; private readonly editOption: ListItemOption = { name: 'Edit', icon: 'edit', onClickCallback: (search: ListedSearch) => { this.onEditQueryClick(search); }, }; private readonly saveOption: ListItemOption = { name: 'Save', icon: 'save', }; readonly savedSearchOptions: ListItemOption[] = [ { name: 'Run', icon: 'play_arrow', onClickCallback: (search: ListedSearch) => { Analytics.TraceSearch.logQueryRequested('saved'); this.onRunQueryFromOptionsClick(search); }, }, this.editOption, { name: 'Delete', icon: 'delete', onClickCallback: (search: ListedSearch) => { this.onDeleteQueryClick(search); }, }, ]; readonly recentSearchOptions: ListItemOption[] = [ { name: 'Run', icon: 'play_arrow', onClickCallback: (search: ListedSearch) => { Analytics.TraceSearch.logQueryRequested('recent'); this.onRunQueryFromOptionsClick(search); }, }, this.editOption, this.saveOption, ]; readonly globalSearchText = ` Write an SQL query in the field below, and run the search. \ Results will be shown in a tabular view and you can optionally visualize them in the timeline. \ `; readonly SEARCH_VIEWS = SEARCH_VIEWS; constructor(@Inject(ElementRef) private elementRef: ElementRef) { super(); } ngAfterViewInit() { this.saveOption.menu = this.saveQueryField; } ngOnChanges(simpleChanges: SimpleChanges) { if (this.initializing && this.inputData?.initialized) { this.initializing = false; } this.updateSearchSections(simpleChanges); if (this.tryPropagateRunFromOptions()) { return; } this.tryHandleQueryCompleted(); } ngAfterContentChecked() { this.tryPropagateEditFromOptions(); } onGlobalSearchClick() { if (!this.initializing && !this.inputData?.initialized) { this.initializing = true; const event = new CustomEvent(ViewerEvents.GlobalSearchSectionClick); this.elementRef.nativeElement.dispatchEvent(event); } } searchQuery(query: string, uid: number) { this.runningQueryUid = uid; const section = assertDefined( this.searchSections.find((s) => s.uid === uid), ); section.lastQueryExecutionTime = undefined; section.lastQueryStartTime = Date.now(); const event = new CustomEvent(ViewerEvents.SearchQueryClick, { detail: new SearchQueryClickDetail(query, uid), }); this.elementRef.nativeElement.dispatchEvent(event); } onSaveQueryClick(query: string, control: FormControl) { if (control.invalid) { return; } const event = new CustomEvent(ViewerEvents.SaveQueryClick, { detail: new SaveQueryClickDetail(query, assertDefined(control.value)), }); this.elementRef.nativeElement.dispatchEvent(event); Analytics.TraceSearch.logQuerySaved(); control.reset(); } onHeaderClick(accordionItem: CdkAccordionItem) { accordionItem.toggle(); } clearQuery(uid: number) { const event = new CustomEvent(ViewerEvents.ClearQueryClick, { detail: new ClearQueryClickDetail(uid), }); this.elementRef.nativeElement.dispatchEvent(event); } addQuery(query?: string) { const event = new CustomEvent(ViewerEvents.AddQueryClick, { detail: query ? new AddQueryClickDetail(query) : undefined, }); this.elementRef.nativeElement.dispatchEvent(event); } getCurrentSearchesWithResults(): CurrentSearch[] { return assertDefined(this.inputData).currentSearches.filter( (search) => search.result !== undefined, ); } getCurrentSearchByUid(uid: number): CurrentSearch | undefined { return this.inputData?.currentSearches.find((search) => search.uid === uid); } getExecutedQueryForSearchSection(uid: number): string | undefined { return this.runningQueryUid !== uid ? this.getCurrentSearchByUid(uid)?.query : undefined; } getQueryLabel(uid: number): string { return 'Query ' + uid; } showResultsPlaceholder(): boolean { return ( this.runningQueryUid === undefined && this.getCurrentSearchesWithResults().length === 0 ); } onSearchTabChanged() { const finalComponent = assertDefined(this.activeSearchComponents).last; if (assertDefined(this.matTabGroups).first.selectedIndex === 0) { finalComponent.elementRef.nativeElement.scrollIntoView(); } } private updateSearchSections(simpleChanges: SimpleChanges) { const currentSearches = this.inputData?.currentSearches; const previousSearches: CurrentSearch[] | undefined = simpleChanges['inputData']?.previousValue?.currentSearches; currentSearches?.forEach((search) => { if (!this.searchSections.some((s) => s.uid === search.uid)) { this.searchSections.push({ uid: search.uid, saveQueryNameControl: this.makeSaveQueryNameControl(), }); } }); previousSearches?.forEach((search) => { if (!currentSearches?.some((curr) => curr.uid === search.uid)) { const i = this.searchSections.findIndex((a) => a.uid === search.uid); this.searchSections.splice(i, 1); } }); } private tryPropagateRunFromOptions(): boolean { if ( this.runFromOptions && this.runningQueryUid === undefined && this.inputData?.currentSearches ) { const lastSearch = this.inputData.currentSearches[ this.inputData.currentSearches.length - 1 ]; this.searchQuery(assertDefined(lastSearch.query), lastSearch.uid); this.runFromOptions = false; return true; } return false; } private tryPropagateEditFromOptions() { if (this.editFromOptions) { const currentSearches = assertDefined(this.inputData).currentSearches; if (currentSearches.length !== this.activeSearchComponents?.length) { return; } const lastSearch = currentSearches[currentSearches.length - 1]; if (lastSearch.query) { this.updateLastSectionTextAndShowTab(lastSearch.query); this.editFromOptions = false; } } } private updateLastSectionTextAndShowTab(text: string) { assertDefined( this.activeSearchComponents?.get(this.searchSections.length - 1), ).updateText(text); assertDefined(this.matTabGroups).first.selectedIndex = 0; } private tryHandleQueryCompleted() { const currentSearch = this.runningQueryUid !== undefined ? this.getCurrentSearchByUid(this.runningQueryUid) : undefined; if (this.runningQueryUid !== undefined && currentSearch !== undefined) { const sectionIndex = this.searchSections.findIndex( (s) => s.uid === this.runningQueryUid, ); const section = this.searchSections[sectionIndex]; if (!this.inputData?.lastTraceFailed) { this.activeSearchComponents ?.get(sectionIndex) ?.updateText(currentSearch?.query ?? ''); section.saveQueryNameControl.setValue( this.getQueryLabel(assertDefined(this.runningQueryUid)), ); assertDefined(this.matTabGroups).last.selectedIndex = sectionIndex; } const executionTimeMs = Date.now() - assertDefined(section.lastQueryStartTime); Analytics.TraceSearch.logQueryExecutionTime(executionTimeMs); section.lastQueryExecutionTime = new TimeDuration( BigInt(executionTimeMs * TIME_UNIT_TO_NANO.ms), ).format(); section.lastQueryStartTime = undefined; this.runningQueryUid = undefined; } } private onRunQueryFromOptionsClick(search: ListedSearch) { const lastUid = this.getLastUid(); if (this.getCurrentSearchByUid(lastUid)?.result) { this.runFromOptions = true; this.addQuery(search.query); } else { this.searchQuery(search.query, lastUid); } } private getLastUid(): number { return this.searchSections[this.searchSections.length - 1].uid; } private onEditQueryClick(search: ListedSearch) { const currentSearches = assertDefined(this.inputData).currentSearches; const lastCurrentSearch = currentSearches[currentSearches.length - 1]; if (lastCurrentSearch.result !== undefined) { this.editFromOptions = true; this.addQuery(search.query); return; } this.updateLastSectionTextAndShowTab(search.query); } private onDeleteQueryClick(search: ListedSearch) { const event = new CustomEvent(ViewerEvents.DeleteSavedQueryClick, { detail: new DeleteSavedQueryClickDetail(search), }); this.elementRef.nativeElement.dispatchEvent(event); } private makeSaveQueryNameControl() { return new FormControl( '', assertDefined( Validators.compose([ Validators.required, (control: FormControl) => this.validateSearchQuerySaveName( control, this.inputData?.savedSearches ?? [], ), ]), ), ); } private validateSearchQuerySaveName( control: FormControl, savedSearches: ListedSearch[], ): ValidationErrors | null { const valid = control.value && !savedSearches.some((search) => search.name === control.value); return !valid ? {invalidInput: control.value} : null; } } interface SearchSection { uid: number; saveQueryNameControl: FormControl; lastQueryExecutionTime?: string; lastQueryStartTime?: number; }