• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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">&#xe5db;</md-icon>
628      Jump to Bottom
629    </md-filled-button>
630  `;
631}
632