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 {CdkVirtualScrollViewport} from '@angular/cdk/scrolling'; 18import { 19 Component, 20 ElementRef, 21 EventEmitter, 22 HostListener, 23 Inject, 24 Input, 25 Output, 26 ViewChild, 27} from '@angular/core'; 28import {MatSelectChange} from '@angular/material/select'; 29 30import {DOMUtils} from 'common/dom_utils'; 31import {Timestamp, TimestampFormatType} from 'common/time/time'; 32import {TimeUtils} from 'common/time/time_utils'; 33import {TraceType} from 'trace/trace_type'; 34import {TextFilter} from 'viewers/common/text_filter'; 35import {LogEntry, LogField, LogHeader} from 'viewers/common/ui_data_log'; 36import { 37 LogFilterChangeDetail, 38 LogTextFilterChangeDetail, 39 TimestampClickDetail, 40 ViewerEvents, 41} from 'viewers/common/viewer_events'; 42import { 43 inlineButtonStyle, 44 timeButtonStyle, 45} from 'viewers/components/styles/clickable_property.styles'; 46import {currentElementStyle} from 'viewers/components/styles/current_element.styles'; 47import {logComponentStyles} from 'viewers/components/styles/log_component.styles'; 48import {selectedElementStyle} from 'viewers/components/styles/selected_element.styles'; 49import { 50 viewerCardInnerStyle, 51 viewerCardStyle, 52} from 'viewers/components/styles/viewer_card.styles'; 53 54@Component({ 55 selector: 'log-view', 56 template: ` 57 <div class="view-header" *ngIf="title"> 58 <div class="title-section"> 59 <collapsible-section-title 60 class="log-title" 61 [title]="title" 62 (collapseButtonClicked)="collapseButtonClicked.emit()"></collapsible-section-title> 63 64 <div class="filters" *ngIf="showFiltersInTitle && getHeadersWithFilters().length > 0"> 65 <div class="filter" *ngFor="let header of getHeadersWithFilters()" 66 [class]="header.spec.cssClass"> 67 <select-with-filter 68 *ngIf="(header.filter.options?.length ?? 0) > 0" 69 [label]="header.spec.name" 70 [options]="header.filter.options" 71 [outerFilterWidth]="header.filter.outerFilterWidthCss" 72 [innerFilterWidth]="header.filter.innerFilterWidthCss" 73 formFieldClass="no-border-top-field" 74 (selectChange)="onFilterChange($event, header)"> 75 </select-with-filter> 76 </div> 77 </div> 78 </div> 79 </div> 80 81 <div class="entries" [class.padded]="padEntries"> 82 <div class="headers table-header" *ngIf="headers.length > 0"> 83 <div *ngIf="showTraceEntryTimes" class="time"> 84 <button 85 color="primary" 86 mat-button 87 class="time-button go-to-current-time" 88 *ngIf="showCurrentTimeButton" 89 (click)="onGoToCurrentTimeClick()"> 90 Go to Current Time 91 </button> 92 </div> 93 94 <ng-container *ngFor="let header of headers"> 95 <div 96 *ngIf="!isHeaderWithFilter(header)" 97 class="mat-body-2 header" 98 [class]="header.spec.cssClass"> 99 {{header.spec.name}}</div> 100 101 <div 102 *ngIf="isHeaderWithFilter(header) && !showFiltersInTitle" 103 class="filter mat-body-2" 104 [class]="header.spec.cssClass"> 105 <select-with-filter 106 *ngIf="(header.filter.options?.length ?? 0) > 0" 107 [label]="header.spec.name" 108 [options]="header.filter.options" 109 [outerFilterWidth]="header.filter.outerFilterWidthCss" 110 [innerFilterWidth]="header.filter.innerFilterWidthCss" 111 appearance="none" 112 formFieldClass="no-padding-field" 113 (selectChange)="onFilterChange($event, header)"> 114 </select-with-filter> 115 116 <search-box 117 *ngIf="header.filter.textFilter" 118 [textFilter]="header.filter.textFilter" 119 [label]="header.spec.name" 120 [filterName]="header.spec.name" 121 appearance="none" 122 [formFieldClass]=" 123 'wide-field no-padding-field center-field ' 124 + header.spec.cssClass 125 + (header.filter.textFilter.filterString?.length === 0 ? ' mat-body-2' : '') 126 " 127 height="fit-content" 128 (filterChange)="onSearchBoxChange($event, header)"></search-box> 129 </div> 130 </ng-container> 131 </div> 132 133 <div class="placeholder-text mat-body-1" *ngIf="!isFetchingData && entries.length === 0"> No entries found. </div> 134 135 <div class="fetching-data mat-body-1" *ngIf="isFetchingData"> 136 <span class="message-with-spinner"> 137 <span>Fetching all data</span> 138 <mat-spinner [diameter]="20"></mat-spinner> 139 </span> 140 </div> 141 142 <cdk-virtual-scroll-viewport 143 *ngIf="isTransactions()" 144 transactionsVirtualScroll 145 class="scroll" 146 [scrollItems]="entries"> 147 <ng-container 148 *cdkVirtualFor="let entry of entries; let i = index" 149 [ngTemplateOutlet]="content" 150 [ngTemplateOutletContext]="{entry: entry, i: i}"> </ng-container> 151 </cdk-virtual-scroll-viewport> 152 153 <cdk-virtual-scroll-viewport 154 *ngIf="isProtolog()" 155 protologVirtualScroll 156 class="scroll" 157 [scrollItems]="entries"> 158 <ng-container 159 *cdkVirtualFor="let entry of entries; let i = index" 160 [ngTemplateOutlet]="content" 161 [ngTemplateOutletContext]="{entry: entry, i: i}"> </ng-container> 162 </cdk-virtual-scroll-viewport> 163 164 <cdk-virtual-scroll-viewport 165 *ngIf="isTransitions()" 166 transitionsVirtualScroll 167 class="scroll" 168 [scrollItems]="entries"> 169 <ng-container 170 *cdkVirtualFor="let entry of entries; let i = index" 171 [ngTemplateOutlet]="content" 172 [ngTemplateOutletContext]="{entry: entry, i: i}"> </ng-container> 173 </cdk-virtual-scroll-viewport> 174 175 <cdk-virtual-scroll-viewport 176 *ngIf="isFixedSizeScrollViewport()" 177 [itemSize]="36" 178 [minBufferPx]="1000" 179 [maxBufferPx]="2000" 180 class="scroll"> 181 <ng-container 182 *cdkVirtualFor="let entry of entries; let i = index" 183 [ngTemplateOutlet]="content" 184 [ngTemplateOutletContext]="{entry: entry, i: i}"> </ng-container> 185 </cdk-virtual-scroll-viewport> 186 187 <ng-template #content let-entry="entry" let-i="i"> 188 <div 189 class="entry" 190 [attr.item-id]="i" 191 [class.current]="isCurrentEntry(i)" 192 [class.selected]="isSelectedEntry(i)" 193 (click)="onEntryClicked(i)"> 194 <div *ngIf="showTraceEntryTimes" class="time"> 195 <button 196 mat-button 197 class="time-button" 198 color="primary" 199 (click)="onTraceEntryTimestampClick($event, entry)" 200 [disabled]="!entry.traceEntry.hasValidTimestamp()"> 201 {{ formatTimestamp(entry.traceEntry.getTimestamp()) }} 202 </button> 203 </div> 204 205 <div [class]="field.spec.cssClass" *ngFor="let field of entry.fields; index as i"> 206 <span class="mat-body-1" *ngIf="!showFieldButton(entry, field)">{{ field.value }}</span> 207 <button 208 *ngIf="showFieldButton(entry, field)" 209 mat-button 210 class="time-button" 211 color="primary" 212 (click)="onFieldButtonClick($event, entry, field)"> 213 {{ formatFieldButton(field) }} 214 </button> 215 <mat-icon 216 *ngIf="field.icon" 217 aria-hidden="false" 218 [style]="{color: field.iconColor}"> {{field.icon}} </mat-icon> 219 </div> 220 </div> 221 </ng-template> 222 </div> 223 `, 224 styles: [ 225 ` 226 .view-header { 227 display: flex; 228 flex-direction: column; 229 flex: 0 0 auto 230 } 231 .message-with-spinner { 232 display: flex; 233 flex-direction: row; 234 align-items: center; 235 justify-content: center; 236 } 237 `, 238 selectedElementStyle, 239 currentElementStyle, 240 timeButtonStyle, 241 inlineButtonStyle, 242 viewerCardStyle, 243 viewerCardInnerStyle, 244 logComponentStyles, 245 ], 246}) 247export class LogComponent { 248 emptyFilterValue = ''; 249 private lastClickedTimestamp: Timestamp | undefined; 250 251 @Input() title: string | undefined; 252 @Input() selectedIndex: number | undefined; 253 @Input() scrollToIndex: number | undefined; 254 @Input() currentIndex: number | undefined; 255 @Input() headers: LogHeader[] = []; 256 @Input() entries: LogEntry[] = []; 257 @Input() showCurrentTimeButton = true; 258 @Input() traceType: TraceType | undefined; 259 @Input() showTraceEntryTimes = true; 260 @Input() showFiltersInTitle = false; 261 @Input() padEntries = true; 262 @Input() isFetchingData = false; 263 264 @Output() collapseButtonClicked = new EventEmitter(); 265 266 @ViewChild(CdkVirtualScrollViewport) 267 scrollComponent?: CdkVirtualScrollViewport; 268 269 constructor( 270 @Inject(ElementRef) private elementRef: ElementRef<HTMLElement>, 271 ) {} 272 273 getHeadersWithFilters() { 274 return this.headers.filter((header) => this.isHeaderWithFilter(header)); 275 } 276 277 isHeaderWithFilter(header: LogHeader): boolean { 278 return header.filter !== undefined; 279 } 280 281 showFieldButton(entry: LogEntry, field: LogField): boolean { 282 const propagateEntryTimestamp = 283 !!field.propagateEntryTimestamp && entry.traceEntry.hasValidTimestamp(); 284 return field.value instanceof Timestamp || propagateEntryTimestamp; 285 } 286 287 formatFieldButton(field: LogField): string | number { 288 return field.value instanceof Timestamp 289 ? this.formatTimestamp(field.value) 290 : field.value; 291 } 292 293 areMultipleDatesPresent(): boolean { 294 return ( 295 this.entries.at(0)?.traceEntry.getFullTrace().spansMultipleDates() ?? 296 false 297 ); 298 } 299 300 formatTimestamp(timestamp: Timestamp) { 301 if (!this.areMultipleDatesPresent()) { 302 return timestamp.format(TimestampFormatType.DROP_DATE); 303 } 304 return timestamp.format(); 305 } 306 307 ngOnChanges() { 308 if ( 309 this.scrollToIndex !== undefined && 310 this.lastClickedTimestamp !== 311 this.entries.at(this.scrollToIndex)?.traceEntry.getTimestamp() 312 ) { 313 this.scrollComponent?.scrollToIndex(Math.max(0, this.scrollToIndex - 1)); 314 } 315 } 316 317 async ngAfterContentInit() { 318 await TimeUtils.sleepMs(10); 319 this.updateTableMarginEnd(); 320 } 321 322 @HostListener('window:resize', ['$event']) 323 onResize(event: Event) { 324 this.updateTableMarginEnd(); 325 } 326 327 onFilterChange(event: MatSelectChange, header: LogHeader) { 328 this.emitEvent( 329 ViewerEvents.LogFilterChange, 330 new LogFilterChangeDetail(header, event.value), 331 ); 332 } 333 334 onSearchBoxChange(detail: TextFilter, header: LogHeader) { 335 this.emitEvent( 336 ViewerEvents.LogTextFilterChange, 337 new LogTextFilterChangeDetail(header, detail), 338 ); 339 } 340 341 onEntryClicked(index: number) { 342 this.emitEvent(ViewerEvents.LogEntryClick, index); 343 } 344 345 onGoToCurrentTimeClick() { 346 if (this.currentIndex !== undefined && this.scrollComponent) { 347 this.scrollComponent.scrollToIndex(this.currentIndex); 348 } 349 } 350 351 onTraceEntryTimestampClick(event: MouseEvent, entry: LogEntry) { 352 event.stopPropagation(); 353 this.lastClickedTimestamp = entry.traceEntry.getTimestamp(); 354 this.emitEvent( 355 ViewerEvents.TimestampClick, 356 new TimestampClickDetail(entry.traceEntry), 357 ); 358 } 359 360 onFieldButtonClick(event: MouseEvent, entry: LogEntry, field: LogField) { 361 event.stopPropagation(); 362 if (field.propagateEntryTimestamp) { 363 this.onTraceEntryTimestampClick(event, entry); 364 } else if (field.value instanceof Timestamp) { 365 this.onRawTimestampClick(field.value as Timestamp); 366 } 367 } 368 369 @HostListener('document:keydown', ['$event']) 370 async handleKeyboardEvent(event: KeyboardEvent) { 371 const logComponentVisible = DOMUtils.isElementVisible( 372 this.elementRef.nativeElement, 373 ); 374 if (event.key === 'ArrowDown' && logComponentVisible) { 375 event.stopPropagation(); 376 event.preventDefault(); 377 this.emitEvent(ViewerEvents.ArrowDownPress); 378 } 379 if (event.key === 'ArrowUp' && logComponentVisible) { 380 event.stopPropagation(); 381 event.preventDefault(); 382 this.emitEvent(ViewerEvents.ArrowUpPress); 383 } 384 } 385 386 isCurrentEntry(index: number): boolean { 387 return index === this.currentIndex; 388 } 389 390 isSelectedEntry(index: number): boolean { 391 return index === this.selectedIndex; 392 } 393 394 isTransactions() { 395 return this.traceType === TraceType.TRANSACTIONS; 396 } 397 398 isProtolog() { 399 return this.traceType === TraceType.PROTO_LOG; 400 } 401 402 isTransitions() { 403 return this.traceType === TraceType.TRANSITION; 404 } 405 406 isFixedSizeScrollViewport() { 407 return !( 408 this.isTransactions() || 409 this.isProtolog() || 410 this.isTransitions() 411 ); 412 } 413 414 updateTableMarginEnd() { 415 const tableHeader = 416 this.elementRef.nativeElement.querySelector<HTMLElement>('.table-header'); 417 if (!tableHeader) { 418 return; 419 } 420 const el = this.scrollComponent?.elementRef.nativeElement; 421 if (el && el.scrollHeight > el.offsetHeight) { 422 tableHeader.style.marginInlineEnd = 423 el.offsetWidth - el.scrollWidth + 'px'; 424 } else { 425 tableHeader.style.marginInlineEnd = ''; 426 } 427 } 428 429 private onRawTimestampClick(value: Timestamp) { 430 this.emitEvent( 431 ViewerEvents.TimestampClick, 432 new TimestampClickDetail(undefined, value), 433 ); 434 } 435 436 private emitEvent(event: ViewerEvents, data?: object | number) { 437 const customEvent = new CustomEvent(event, { 438 bubbles: true, 439 detail: data, 440 }); 441 this.elementRef.nativeElement.dispatchEvent(customEvent); 442 } 443} 444