xref: /aosp_15_r20/external/pigweed/pw_web/log-viewer/src/components/log-list/log-list.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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">&#xe5db;</md-icon>
656      Jump to Bottom
657    </md-filled-button>
658  `;
659}
660