/*
* 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.
Run custom SQL queries on Perfetto traces. Use specialized SQL views to aid with searching:
{{ accordionItem.expanded ? 'arrow_drop_down' : 'chevron_right' }}
{{searchView.name}}
Use to search {{searchView.dataType}} data.
Spec:
{{column.name}} |
{{column.desc}} |
Examples:
{{example.query}}
{{example.desc}}
`,
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;
}