xref: /aosp_15_r20/development/tools/winscope/src/viewers/viewer_search/viewer_search_component.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
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