blob: 58d635ce78c2abcfb06ee70fc4fe43799ad81235 [file] [log] [blame]
// Copyright 2023 The Pigweed Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.
import { LitElement, html, PropertyValues, TemplateResult } from 'lit';
import {
customElement,
property,
query,
queryAll,
state,
} from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styles } from './log-list.styles';
import { LogEntry, Severity, TableColumn } from '../../shared/interfaces';
import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
import '@lit-labs/virtualizer';
/**
* A sub-component of the log view which takes filtered logs and renders them in
* a virtualized HTML table.
*
* @element log-list
*/
@customElement('log-list')
export class LogList extends LitElement {
static styles = styles;
/** The `id` of the parent view containing this log list. */
@property()
viewId = '';
/** An array of log entries to be displayed. */
@property({ type: Array })
logs: LogEntry[] = [];
/** A string representing the value contained in the search field. */
@property({ type: String })
searchText = '';
/** Whether line wrapping in table cells should be used. */
@property({ type: Boolean })
lineWrap = false;
@property({ type: Array })
columnData: TableColumn[] = [];
/** Indicates whether the table content is overflowing to the right. */
@state()
private _isOverflowingToRight = false;
/**
* Indicates whether to automatically scroll the table container to the bottom
* when new log entries are added.
*/
@state()
private _autoscrollIsEnabled = true;
/** A number representing the scroll percentage in the horizontal direction. */
@state()
private _scrollPercentageLeft = 0;
@query('.table-container') private _tableContainer!: HTMLDivElement;
@query('table') private _table!: HTMLTableElement;
@query('tbody') private _tableBody!: HTMLTableSectionElement;
@queryAll('tr') private _tableRows!: HTMLTableRowElement[];
/** Indicates whether to enable autosizing of incoming log entries. */
private _autosizeLocked = false;
/** The number of times the `logs` array has been updated. */
private logUpdateCount: number = 0;
/** The last known vertical scroll position of the table container. */
private lastScrollTop: number = 0;
/** The maximum number of log entries to render in the list. */
private readonly MAX_ENTRIES = 100_000;
/** The maximum number of log updates until autosize is disabled. */
private readonly AUTOSIZE_LIMIT: number = 8;
/** The minimum width (in px) for table columns. */
private readonly MIN_COL_WIDTH: number = 52;
/**
* Data used for column resizing including the column index, the starting
* mouse position (X-coordinate), and the initial width of the column.
*/
private columnResizeData: {
columnIndex: number;
startX: number;
startWidth: number;
} | null = null;
firstUpdated() {
setInterval(() => this.updateHorizontalOverflowState(), 1000);
this._tableContainer.addEventListener('scroll', this.handleTableScroll);
this._tableBody.addEventListener('rangeChanged', this.onRangeChanged);
const newRowObserver = new MutationObserver(this.onTableRowAdded);
newRowObserver.observe(this._table, {
childList: true,
subtree: true,
});
}
updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (
changedProperties.has('offsetWidth') ||
changedProperties.has('scrollWidth')
) {
this.updateHorizontalOverflowState();
}
if (changedProperties.has('logs')) {
this.logUpdateCount++;
this.handleTableScroll();
}
if (changedProperties.has('columnData')) {
this.updateColumnWidths(this.generateGridTemplateColumns());
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._tableContainer.removeEventListener('scroll', this.handleTableScroll);
this._tableBody.removeEventListener('rangeChanged', this.onRangeChanged);
}
private onTableRowAdded = () => {
if (!this._autosizeLocked) {
this.autosizeColumns();
}
// Disable auto-sizing once a certain number of updates to the logs array have been made
if (this.logUpdateCount >= this.AUTOSIZE_LIMIT) {
this._autosizeLocked = true;
}
};
/** Called when the Lit virtualizer updates its range of entries. */
private onRangeChanged = () => {
if (this._autoscrollIsEnabled) {
this.scrollTableToBottom();
}
};
/** Scrolls to the bottom of the table container. */
private scrollTableToBottom() {
const container = this._tableContainer;
// TODO: b/298097109 - Refactor `setTimeout` usage
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 0); // Complete any rendering tasks before scrolling
}
private onJumpToBottomButtonClick() {
this._autoscrollIsEnabled = true;
this.scrollTableToBottom();
}
/**
* Calculates the maximum column widths for the table and updates the table
* rows.
*/
private autosizeColumns = (rows = this._tableRows) => {
// Iterate through each row to find the maximum width in each column
rows.forEach((row) => {
const cells = Array.from(row.children).filter(
(cell) => !cell.hasAttribute('hidden'),
) as HTMLTableCellElement[];
cells.forEach((cell, columnIndex) => {
if (columnIndex === 0) return;
const textLength = cell.textContent?.trim().length || 0;
if (!this._autosizeLocked) {
// Update the preferred width if it's smaller than the new one
if (this.columnData[columnIndex]) {
this.columnData[columnIndex].characterLength = Math.max(
this.columnData[columnIndex].characterLength,
textLength,
);
} else {
// Initialize if the column data for this index does not exist
this.columnData[columnIndex] = {
fieldName: '',
characterLength: textLength,
manualWidth: null,
isVisible: true,
};
}
}
});
});
};
private generateGridTemplateColumns(
newWidth?: number,
resizingIndex?: number,
): string {
let gridTemplateColumns = '';
this.columnData.forEach((col, i) => {
let columnValue = '';
if (col.isVisible) {
if (i === resizingIndex) {
columnValue = `${newWidth}px`;
} else if (col.manualWidth !== null) {
columnValue = `${col.manualWidth}px`;
} else {
if (i === 0) {
columnValue = '3rem';
} else {
const chWidth = col.characterLength;
const padding = 34;
columnValue = `clamp(${this.MIN_COL_WIDTH}px, ${chWidth}ch + ${padding}px, 80ch)`;
}
}
gridTemplateColumns += columnValue + ' ';
}
});
return gridTemplateColumns.trim();
}
private updateColumnWidths(gridTemplateColumns: string) {
this.style.setProperty('--column-widths', gridTemplateColumns);
}
/**
* Highlights text content within the table cell based on the current filter
* value.
*
* @param {string} text - The table cell text to be processed.
*/
private highlightMatchedText(text: string): TemplateResult[] {
if (!this.searchText) {
return [html`${text}`];
}
const searchPhrase = this.searchText?.replace(/(^"|')|("|'$)/g, '');
const escapedsearchText = searchPhrase.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
const regex = new RegExp(`(${escapedsearchText})`, 'gi');
const parts = text.split(regex);
return parts.map((part) =>
regex.test(part) ? html`<mark>${part}</mark>` : html`${part}`,
);
}
/** Updates horizontal overflow state. */
private updateHorizontalOverflowState() {
const containerWidth = this.offsetWidth;
const tableWidth = this._tableContainer.scrollWidth;
this._isOverflowingToRight = tableWidth > containerWidth;
}
/**
* Calculates scroll-related properties and updates the component's state when
* the user scrolls the table.
*/
private handleTableScroll = () => {
const container = this._tableContainer;
const currentScrollTop = container.scrollTop;
const containerWidth = container.offsetWidth;
const scrollLeft = container.scrollLeft;
const scrollY =
container.scrollHeight - currentScrollTop - container.clientHeight;
const maxScrollLeft = container.scrollWidth - containerWidth;
// Determine scroll direction and update the last known scroll position
const isScrollingVertically = currentScrollTop !== this.lastScrollTop;
const isScrollingUp = currentScrollTop < this.lastScrollTop;
this.lastScrollTop = currentScrollTop;
const logsAreCleared = this.logs.length == 0;
if (logsAreCleared) {
this._autoscrollIsEnabled = true;
return;
}
// Run autoscroll logic if scrolling vertically
if (!isScrollingVertically) {
this._scrollPercentageLeft = scrollLeft / maxScrollLeft || 0;
return;
}
// Scroll direction up, disable autoscroll
if (isScrollingUp) {
this._autoscrollIsEnabled = false;
return;
}
// Scroll direction down, enable autoscroll if near the bottom
if (Math.abs(scrollY) <= 1) {
this._autoscrollIsEnabled = true;
return;
}
};
/**
* Handles column resizing.
*
* @param {MouseEvent} event - The mouse event triggered during column
* resizing.
* @param {number} columnIndex - An index specifying the column being resized.
*/
private handleColumnResizeStart(event: MouseEvent, columnIndex: number) {
event.preventDefault();
// Check if the corresponding index in columnData is not visible. If not,
// check the columnIndex - 1th element until one isn't hidden.
while (
this.columnData[columnIndex] &&
!this.columnData[columnIndex].isVisible
) {
columnIndex--;
if (columnIndex < 0) {
// Exit the loop if we've checked all possible columns
return;
}
}
// If no visible columns are found, return early
if (columnIndex < 0) return;
const startX = event.clientX;
const columnHeader = this._table.querySelector(
`th:nth-child(${columnIndex + 1})`,
) as HTMLTableCellElement;
if (!columnHeader) return;
const startWidth = columnHeader.offsetWidth;
this.columnResizeData = {
columnIndex: columnIndex,
startX,
startWidth,
};
const handleColumnResize = (event: MouseEvent) => {
this.handleColumnResize(event);
};
const handleColumnResizeEnd = () => {
this.columnResizeData = null;
document.removeEventListener('mousemove', handleColumnResize);
document.removeEventListener('mouseup', handleColumnResizeEnd);
// Communicate column data changes back to parent Log View
const updateColumnData = new CustomEvent('update-column-data', {
detail: this.columnData,
});
this.dispatchEvent(updateColumnData);
};
document.addEventListener('mousemove', handleColumnResize);
document.addEventListener('mouseup', handleColumnResizeEnd);
}
/**
* Adjusts the column width during a column resize.
*
* @param {MouseEvent} event - The mouse event object.
*/
private handleColumnResize(event: MouseEvent) {
if (!this.columnResizeData) return;
const { columnIndex, startX, startWidth } = this.columnResizeData;
const offsetX = event.clientX - startX;
const newWidth = Math.max(startWidth + offsetX, this.MIN_COL_WIDTH);
// Ensure the column index exists in columnData
if (this.columnData[columnIndex]) {
this.columnData[columnIndex].manualWidth = newWidth;
}
const gridTemplateColumns = this.generateGridTemplateColumns(
newWidth,
columnIndex,
);
this.updateColumnWidths(gridTemplateColumns);
}
render() {
const logsDisplayed: LogEntry[] = this.logs.slice(0, this.MAX_ENTRIES);
return html`
<div
class="table-container"
role="log"
@scroll="${this.handleTableScroll}"
>
<table>
<thead>
${this.tableHeaderRow()}
</thead>
<tbody>
${virtualize({
items: logsDisplayed,
renderItem: (log) => html`${this.tableDataRow(log)}`,
})}
</tbody>
</table>
${this.overflowIndicators()} ${this.jumpToBottomButton()}
</div>
`;
}
private tableHeaderRow() {
return html`
<tr>
${this.columnData.map((columnData, columnIndex) =>
this.tableHeaderCell(
columnData.fieldName,
columnIndex,
columnData.isVisible,
),
)}
</tr>
`;
}
private tableHeaderCell(
fieldKey: string,
columnIndex: number,
isVisible: boolean,
) {
return html`
<th title="${fieldKey}" ?hidden=${!isVisible}>
${fieldKey}
${columnIndex > 0 ? this.resizeHandle(columnIndex - 1) : html``}
</th>
`;
}
private resizeHandle(columnIndex: number) {
if (columnIndex === 0) {
return html`
<span class="resize-handle" style="pointer-events: none"></span>
`;
}
return html`
<span
class="resize-handle"
@mousedown="${(event: MouseEvent) =>
this.handleColumnResizeStart(event, columnIndex)}"
></span>
`;
}
private tableDataRow(log: LogEntry) {
const classes = {
'log-row': true,
'log-row--nowrap': !this.lineWrap,
};
const logSeverityClass = ('log-row--' +
(log.severity || Severity.INFO).toLowerCase()) as keyof typeof classes;
classes[logSeverityClass] = true;
return html`
<tr class="${classMap(classes)}">
${this.columnData.map((columnData, columnIndex) =>
this.tableDataCell(
log,
columnData.fieldName,
columnIndex,
columnData.isVisible,
),
)}
</tr>
`;
}
private tableDataCell(
log: LogEntry,
fieldKey: string,
columnIndex: number,
isVisible: boolean,
) {
const field = log.fields.find((f) => f.key === fieldKey) || {
key: fieldKey,
value: '',
};
if (field.key == 'severity') {
const severityIcons = new Map<Severity, string>([
[Severity.WARNING, 'warning'],
[Severity.ERROR, 'cancel'],
[Severity.CRITICAL, 'brightness_alert'],
[Severity.DEBUG, 'bug_report'],
]);
const severityValue = field.value as Severity;
const iconId = severityIcons.get(severityValue) || '';
const toTitleCase = (input: string): string => {
return input.replace(/\b\w+/g, (match) => {
return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase();
});
};
return html`
<td ?hidden=${!isVisible}>
<div class="cell-content">
<md-icon
class="cell-icon"
title="${toTitleCase(field.value.toString())}"
>
${iconId}
</md-icon>
</div>
</td>
`;
}
return html`
<td ?hidden=${!isVisible}>
<div class="cell-content">
<span class="cell-text"
>${this.highlightMatchedText(field.value.toString())}</span
>
</div>
${columnIndex > 0 ? this.resizeHandle(columnIndex - 1) : html``}
</td>
`;
}
private overflowIndicators = () => html`
<div
class="bottom-indicator"
data-visible="${this._autoscrollIsEnabled ? 'false' : 'true'}"
></div>
<div
class="overflow-indicator left-indicator"
style="opacity: ${this._scrollPercentageLeft}"
?hidden="${!this._isOverflowingToRight}"
></div>
<div
class="overflow-indicator right-indicator"
style="opacity: ${1 - this._scrollPercentageLeft}"
?hidden="${!this._isOverflowingToRight}"
></div>
`;
private jumpToBottomButton = () => html`
<md-filled-button
class="jump-to-bottom-btn"
title="Jump to Bottom"
@click="${this.onJumpToBottomButtonClick}"
leading-icon
data-visible="${this._autoscrollIsEnabled ? 'false' : 'true'}"
>
<md-icon slot="icon" aria-hidden="true">arrow_downward</md-icon>
Jump to Bottom
</md-filled-button>
`;
}