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