1// Copyright 2024 The Pigweed Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); you may not 4// use this file except in compliance with the License. You may obtain a copy of 5// the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12// License for the specific language governing permissions and limitations under 13// the License. 14 15import { LitElement, html, PropertyValues, TemplateResult } from 'lit'; 16import { 17 customElement, 18 property, 19 query, 20 queryAll, 21 state, 22} from 'lit/decorators.js'; 23import { classMap } from 'lit/directives/class-map.js'; 24import { styles } from './log-list.styles'; 25import { LogEntry, Level, TableColumn } from '../../shared/interfaces'; 26import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; 27import '@lit-labs/virtualizer'; 28import { debounce } from '../../utils/debounce'; 29import { throttle } from '../../utils/throttle'; 30 31/** 32 * A sub-component of the log view which takes filtered logs and renders them in 33 * a virtualized HTML table. 34 * 35 * @element log-list 36 */ 37@customElement('log-list') 38export class LogList extends LitElement { 39 static styles = styles; 40 41 /** The `id` of the parent view containing this log list. */ 42 @property() 43 viewId = ''; 44 45 /** An array of log entries to be displayed. */ 46 @property({ type: Array }) 47 logs: LogEntry[] = []; 48 49 /** A string representing the value contained in the search field. */ 50 @property({ type: String }) 51 searchText = ''; 52 53 /** Whether line wrapping in table cells should be used. */ 54 @property({ type: Boolean }) 55 lineWrap = true; 56 57 @state() 58 columnData: TableColumn[] = []; 59 60 /** Indicates whether the table content is overflowing to the right. */ 61 @state() 62 private _isOverflowingToRight = false; 63 64 /** 65 * Indicates whether to automatically scroll the table container to the bottom 66 * when new log entries are added. 67 */ 68 @state() 69 private _autoscrollIsEnabled = true; 70 71 /** A number representing the scroll percentage in the horizontal direction. */ 72 @state() 73 private _scrollPercentageLeft = 0; 74 75 @query('.table-container') private _tableContainer!: HTMLDivElement; 76 @query('table') private _table!: HTMLTableElement; 77 @query('tbody') private _tableBody!: HTMLTableSectionElement; 78 @queryAll('tr') private _tableRows!: HTMLTableRowElement[]; 79 80 /** The zoom level based on pixel ratio of the window */ 81 private _zoomLevel: number = Math.round(window.devicePixelRatio * 100); 82 83 /** Indicates whether to enable autosizing of incoming log entries. */ 84 private _autosizeLocked = false; 85 86 /** The number of times the `logs` array has been updated. */ 87 private logUpdateCount = 0; 88 89 /** The last known vertical scroll position of the table container. */ 90 private lastScrollTop = 0; 91 92 /** The maximum number of log entries to render in the list. */ 93 private readonly MAX_ENTRIES = 100_000; 94 95 /** The maximum number of log updates until autosize is disabled. */ 96 private readonly AUTOSIZE_LIMIT: number = 8; 97 98 /** The minimum width (in px) for table columns. */ 99 private readonly MIN_COL_WIDTH: number = 52; 100 101 /** The minimum width (in px) for table columns. */ 102 private readonly LAST_COL_MIN_WIDTH: number = 250; 103 104 /** The delay (in ms) for debouncing column resizing */ 105 private readonly RESIZE_DEBOUNCE_DELAY = 10; 106 107 /** 108 * Data used for column resizing including the column index, the starting 109 * mouse position (X-coordinate), and the initial width of the column. 110 */ 111 private columnResizeData: { 112 columnIndex: number; 113 startX: number; 114 startWidth: number; 115 } | null = null; 116 117 firstUpdated() { 118 this._tableContainer.addEventListener('scroll', this.handleTableScroll); 119 this._tableBody.addEventListener('rangeChanged', this.onRangeChanged); 120 121 const newRowObserver = new MutationObserver(this.onTableRowAdded); 122 newRowObserver.observe(this._table, { 123 childList: true, 124 subtree: true, 125 }); 126 } 127 128 updated(changedProperties: PropertyValues) { 129 super.updated(changedProperties); 130 131 if ( 132 changedProperties.has('offsetWidth') || 133 changedProperties.has('scrollWidth') 134 ) { 135 this.updateHorizontalOverflowState(); 136 } 137 138 if (changedProperties.has('logs')) { 139 this.logUpdateCount++; 140 this.handleTableScroll(); 141 } 142 143 if (changedProperties.has('columnData')) { 144 this.updateColumnWidths(this.generateGridTemplateColumns()); 145 this.updateHorizontalOverflowState(); 146 this.requestUpdate(); 147 } 148 } 149 150 disconnectedCallback() { 151 super.disconnectedCallback(); 152 this._tableContainer.removeEventListener('scroll', this.handleTableScroll); 153 this._tableBody.removeEventListener('rangeChanged', this.onRangeChanged); 154 } 155 156 private onTableRowAdded = () => { 157 if (!this._autosizeLocked) { 158 this.autosizeColumns(); 159 } 160 161 // Disable auto-sizing once a certain number of updates to the logs array have been made 162 if (this.logUpdateCount >= this.AUTOSIZE_LIMIT) { 163 this._autosizeLocked = true; 164 } 165 }; 166 167 /** Called when the Lit virtualizer updates its range of entries. */ 168 private onRangeChanged = () => { 169 if (this._autoscrollIsEnabled) { 170 this.scrollTableToBottom(); 171 } 172 }; 173 174 /** Scrolls to the bottom of the table container. */ 175 private scrollTableToBottom() { 176 const container = this._tableContainer; 177 178 // TODO: b/298097109 - Refactor `setTimeout` usage 179 setTimeout(() => { 180 container.scrollTop = container.scrollHeight; 181 }, 0); // Complete any rendering tasks before scrolling 182 } 183 184 private onJumpToBottomButtonClick() { 185 this._autoscrollIsEnabled = true; 186 this.scrollTableToBottom(); 187 } 188 189 /** 190 * Calculates the maximum column widths for the table and updates the table 191 * rows. 192 */ 193 private autosizeColumns = (rows = this._tableRows) => { 194 // Iterate through each row to find the maximum width in each column 195 const visibleColumnData = this.columnData.filter( 196 (column) => column.isVisible, 197 ); 198 199 rows.forEach((row) => { 200 const cells = Array.from(row.children).filter( 201 (cell) => !cell.hasAttribute('hidden'), 202 ) as HTMLTableCellElement[]; 203 204 cells.forEach((cell, columnIndex) => { 205 if (visibleColumnData[columnIndex].fieldName == 'level') return; 206 207 const textLength = cell.textContent?.trim().length || 0; 208 209 if (!this._autosizeLocked) { 210 // Update the preferred width if it's smaller than the new one 211 if (visibleColumnData[columnIndex]) { 212 visibleColumnData[columnIndex].characterLength = Math.max( 213 visibleColumnData[columnIndex].characterLength, 214 textLength, 215 ); 216 } else { 217 // Initialize if the column data for this index does not exist 218 visibleColumnData[columnIndex] = { 219 fieldName: '', 220 characterLength: textLength, 221 manualWidth: null, 222 isVisible: true, 223 }; 224 } 225 } 226 }); 227 }); 228 229 this.updateColumnWidths(this.generateGridTemplateColumns()); 230 const resizeColumn = new CustomEvent('resize-column', { 231 bubbles: true, 232 composed: true, 233 detail: { 234 viewId: this.viewId, 235 columnData: this.columnData, 236 }, 237 }); 238 239 this.dispatchEvent(resizeColumn); 240 }; 241 242 private generateGridTemplateColumns( 243 newWidth?: number, 244 resizingIndex?: number, 245 ): string { 246 let gridTemplateColumns = ''; 247 248 let lastVisibleCol = -1; 249 for (let i = this.columnData.length - 1; i >= 0; i--) { 250 if (this.columnData[i].isVisible) { 251 lastVisibleCol = i; 252 break; 253 } 254 } 255 256 const calculateColumnWidth = (col: TableColumn, i: number) => { 257 const chWidth = col.characterLength; 258 const padding = 24 + 1; // +1 pixel to avoid ellipsis jitter when highlighting text 259 260 if (i === resizingIndex) { 261 if (i === lastVisibleCol) { 262 return `minmax(${newWidth}px, 1fr)`; 263 } 264 return `${newWidth}px`; 265 } 266 if (col.manualWidth !== null) { 267 if (i === lastVisibleCol) { 268 return `minmax(${col.manualWidth}px, 1fr)`; 269 } 270 return `${col.manualWidth}px`; 271 } 272 if (i === 0) { 273 return `calc(var(--sys-log-viewer-table-cell-icon-size) + 1rem)`; 274 } 275 if (i === lastVisibleCol) { 276 return `minmax(${this.LAST_COL_MIN_WIDTH}px, 1fr)`; 277 } 278 return `clamp(${this.MIN_COL_WIDTH}px, ${chWidth}ch + ${padding}px, 80ch)`; 279 }; 280 281 this.columnData.forEach((column, i) => { 282 if (column.isVisible) { 283 const columnValue = calculateColumnWidth(column, i); 284 gridTemplateColumns += columnValue + ' '; 285 } 286 }); 287 288 return gridTemplateColumns.trim(); 289 } 290 291 private updateColumnWidths(gridTemplateColumns: string) { 292 this.style.setProperty('--column-widths', gridTemplateColumns); 293 } 294 295 /** 296 * Highlights text content within the table cell based on the current filter 297 * value. 298 * 299 * @param {string} text - The table cell text to be processed. 300 */ 301 private highlightMatchedText(text: string): TemplateResult[] { 302 if (!this.searchText) { 303 return [html`${text}`]; 304 } 305 306 const searchPhrase = this.searchText?.replace(/(^"|')|("|'$)/g, ''); 307 const escapedsearchText = searchPhrase.replace( 308 /[.*+?^${}()|[\]\\]/g, 309 '\\$&', 310 ); 311 const regex = new RegExp(`(${escapedsearchText})`, 'gi'); 312 const parts = text.split(regex); 313 return parts.map((part) => 314 regex.test(part) ? html`<mark>${part}</mark>` : html`${part}`, 315 ); 316 } 317 318 /** Updates horizontal overflow state. */ 319 private updateHorizontalOverflowState() { 320 const containerWidth = this.offsetWidth; 321 const tableWidth = this._tableContainer.scrollWidth; 322 323 this._isOverflowingToRight = tableWidth > containerWidth; 324 } 325 326 /** 327 * Calculates scroll-related properties and updates the component's state when 328 * the user scrolls the table. 329 */ 330 private handleTableScroll = () => { 331 const container = this._tableContainer; 332 const currentScrollTop = container.scrollTop; 333 const containerWidth = container.offsetWidth; 334 const scrollLeft = container.scrollLeft; 335 const scrollY = 336 container.scrollHeight - currentScrollTop - container.clientHeight; 337 const maxScrollLeft = container.scrollWidth - containerWidth; 338 339 // Determine scroll direction and update the last known scroll position 340 const isScrollingVertically = currentScrollTop !== this.lastScrollTop; 341 const isScrollingUp = currentScrollTop < this.lastScrollTop; 342 this.lastScrollTop = currentScrollTop; 343 344 const logsAreCleared = this.logs.length == 0; 345 const zoomChanged = 346 this._zoomLevel !== Math.round(window.devicePixelRatio * 100); 347 348 if (logsAreCleared) { 349 this._autoscrollIsEnabled = true; 350 return; 351 } 352 353 // Do not change autoscroll if zoom level on window changed 354 if (zoomChanged) { 355 this._zoomLevel = Math.round(window.devicePixelRatio * 100); 356 return; 357 } 358 359 // Calculate horizontal scroll percentage 360 if (!isScrollingVertically) { 361 this._scrollPercentageLeft = scrollLeft / maxScrollLeft || 0; 362 return; 363 } 364 365 // Scroll direction up, disable autoscroll 366 if (isScrollingUp && Math.abs(scrollY) > 1) { 367 this._autoscrollIsEnabled = false; 368 return; 369 } 370 371 // Scroll direction down, enable autoscroll if near the bottom 372 if (Math.abs(scrollY) <= 1) { 373 this._autoscrollIsEnabled = true; 374 return; 375 } 376 }; 377 378 /** 379 * Handles column resizing. 380 * 381 * @param {MouseEvent} event - The mouse event triggered during column 382 * resizing. 383 * @param {number} columnIndex - An index specifying the column being resized. 384 */ 385 private handleColumnResizeStart(event: MouseEvent, columnIndex: number) { 386 event.preventDefault(); 387 388 // Check if the corresponding index in columnData is not visible. If not, 389 // check the columnIndex - 1th element until one isn't hidden. 390 while ( 391 this.columnData[columnIndex] && 392 !this.columnData[columnIndex].isVisible 393 ) { 394 columnIndex--; 395 if (columnIndex < 0) { 396 // Exit the loop if we've checked all possible columns 397 return; 398 } 399 } 400 401 // If no visible columns are found, return early 402 if (columnIndex < 0) return; 403 404 const startX = event.clientX; 405 const columnHeader = this._table.querySelector( 406 `th:nth-child(${columnIndex + 1})`, 407 ) as HTMLTableCellElement; 408 409 if (!columnHeader) return; 410 411 const startWidth = columnHeader.offsetWidth; 412 413 this.columnResizeData = { 414 columnIndex: columnIndex, 415 startX, 416 startWidth, 417 }; 418 419 const handleColumnResize = throttle((event: MouseEvent) => { 420 this.handleColumnResize(event); 421 }, 16); 422 423 const handleColumnResizeEnd = () => { 424 this.columnResizeData = null; 425 document.removeEventListener('mousemove', handleColumnResize); 426 document.removeEventListener('mouseup', handleColumnResizeEnd); 427 428 // Communicate column data changes back to parent Log View 429 const resizeColumn = new CustomEvent('resize-column', { 430 bubbles: true, 431 composed: true, 432 detail: { 433 viewId: this.viewId, 434 columnData: this.columnData, 435 }, 436 }); 437 438 this.dispatchEvent(resizeColumn); 439 }; 440 441 document.addEventListener('mousemove', handleColumnResize); 442 document.addEventListener('mouseup', handleColumnResizeEnd); 443 } 444 445 /** 446 * Adjusts the column width during a column resize. 447 * 448 * @param {MouseEvent} event - The mouse event object. 449 */ 450 private handleColumnResize(event: MouseEvent) { 451 if (!this.columnResizeData) return; 452 453 const { columnIndex, startX, startWidth } = this.columnResizeData; 454 const offsetX = event.clientX - startX; 455 const newWidth = 456 this.columnData.length - 1 === columnIndex 457 ? Math.max(startWidth + offsetX, this.LAST_COL_MIN_WIDTH) 458 : Math.max(startWidth + offsetX, this.MIN_COL_WIDTH); 459 460 // Ensure the column index exists in columnData 461 if (this.columnData[columnIndex]) { 462 this.columnData[columnIndex].manualWidth = newWidth; 463 } 464 465 const generateGridTemplateColumns = debounce(() => { 466 const gridTemplateColumns = this.generateGridTemplateColumns( 467 newWidth, 468 columnIndex, 469 ); 470 this.updateColumnWidths(gridTemplateColumns); 471 }, this.RESIZE_DEBOUNCE_DELAY); 472 473 generateGridTemplateColumns(); 474 } 475 476 render() { 477 const logsDisplayed: LogEntry[] = this.logs.slice(0, this.MAX_ENTRIES); 478 479 return html` 480 <div 481 class="table-container" 482 role="log" 483 @scroll="${this.handleTableScroll}" 484 > 485 <table> 486 <thead> 487 ${this.tableHeaderRow()} 488 </thead> 489 490 <tbody> 491 ${virtualize({ 492 items: logsDisplayed, 493 renderItem: (log) => html`${this.tableDataRow(log)}`, 494 })} 495 </tbody> 496 </table> 497 ${this.overflowIndicators()} ${this.jumpToBottomButton()} 498 </div> 499 `; 500 } 501 502 private tableHeaderRow() { 503 return html` 504 <tr> 505 ${this.columnData.map((columnData, columnIndex) => 506 this.tableHeaderCell( 507 columnData.fieldName, 508 columnIndex, 509 columnData.isVisible, 510 ), 511 )} 512 </tr> 513 `; 514 } 515 516 private tableHeaderCell( 517 fieldKey: string, 518 columnIndex: number, 519 isVisible: boolean, 520 ) { 521 return html` 522 <th title="${fieldKey}" ?hidden=${!isVisible}> 523 ${fieldKey} ${this.resizeHandle(columnIndex)} 524 </th> 525 `; 526 } 527 528 private resizeHandle(columnIndex: number) { 529 if (columnIndex === 0) { 530 return html` 531 <span class="resize-handle" style="pointer-events: none"></span> 532 `; 533 } 534 535 return html` 536 <span 537 class="resize-handle" 538 @mousedown="${(event: MouseEvent) => 539 this.handleColumnResizeStart(event, columnIndex)}" 540 ></span> 541 `; 542 } 543 544 private tableDataRow(log: LogEntry) { 545 const classes = { 546 'log-row': true, 547 'log-row--nowrap': !this.lineWrap, 548 }; 549 const logLevelClass = ('log-row--' + 550 (log?.level || Level.INFO).toLowerCase()) as keyof typeof classes; 551 classes[logLevelClass] = true; 552 553 return html` 554 <tr class="${classMap(classes)}"> 555 ${this.columnData.map((columnData, columnIndex) => 556 this.tableDataCell( 557 log, 558 columnData.fieldName, 559 columnIndex, 560 columnData.isVisible, 561 ), 562 )} 563 </tr> 564 `; 565 } 566 567 private tableDataCell( 568 log: LogEntry, 569 fieldKey: string, 570 columnIndex: number, 571 isVisible: boolean, 572 ) { 573 const field = log.fields.find((f) => f.key === fieldKey) || { 574 key: fieldKey, 575 value: '', 576 }; 577 578 if (field.key == 'level') { 579 const levelIcons = new Map<Level, string>([ 580 [Level.INFO, `\ue88e`], 581 [Level.WARNING, '\uf083'], 582 [Level.ERROR, '\ue888'], 583 [Level.CRITICAL, '\uf5cf'], 584 [Level.DEBUG, '\ue868'], 585 ]); 586 587 const levelValue: Level = field.value 588 ? (field.value as Level) 589 : log.level 590 ? log.level 591 : Level.INFO; 592 const iconId = levelIcons.get(levelValue) || ''; 593 const toTitleCase = (input: string): string => { 594 return input.replace(/\b\w+/g, (match) => { 595 return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase(); 596 }); 597 }; 598 599 return html` 600 <td class="level-cell" ?hidden=${!isVisible}> 601 <div class="cell-content"> 602 <md-icon 603 class="cell-icon" 604 title="${toTitleCase(field.value.toString())}" 605 > 606 ${iconId} 607 </md-icon> 608 </div> 609 ${this.resizeHandle(columnIndex)} 610 </td> 611 `; 612 } 613 614 return html` 615 <td ?hidden=${!isVisible}> 616 <div class="cell-content"> 617 <span class="cell-text" 618 >${field.value 619 ? this.highlightMatchedText(field.value.toString()) 620 : ''}</span 621 > 622 </div> 623 ${this.resizeHandle(columnIndex)} 624 </td> 625 `; 626 } 627 628 private overflowIndicators = () => html` 629 <div 630 class="bottom-indicator" 631 data-visible="${this._autoscrollIsEnabled ? 'false' : 'true'}" 632 ></div> 633 634 <div 635 class="overflow-indicator left-indicator" 636 style="opacity: ${this._scrollPercentageLeft - 0.5}" 637 ?hidden="${!this._isOverflowingToRight}" 638 ></div> 639 640 <div 641 class="overflow-indicator right-indicator" 642 style="opacity: ${1 - this._scrollPercentageLeft - 0.5}" 643 ?hidden="${!this._isOverflowingToRight}" 644 ></div> 645 `; 646 647 private jumpToBottomButton = () => html` 648 <md-filled-button 649 class="jump-to-bottom-btn" 650 title="Jump to Bottom" 651 @click="${this.onJumpToBottomButtonClick}" 652 leading-icon 653 data-visible="${this._autoscrollIsEnabled ? 'false' : 'true'}" 654 > 655 <md-icon slot="icon" aria-hidden="true"></md-icon> 656 Jump to Bottom 657 </md-filled-button> 658 `; 659} 660