• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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