diff options
Diffstat (limited to 'pw_web/log-viewer/src')
18 files changed, 379 insertions, 61 deletions
diff --git a/pw_web/log-viewer/src/assets/material_symbols_rounded_subset.woff2 b/pw_web/log-viewer/src/assets/material_symbols_rounded_subset.woff2 Binary files differnew file mode 100644 index 000000000..415bc318b --- /dev/null +++ b/pw_web/log-viewer/src/assets/material_symbols_rounded_subset.woff2 diff --git a/pw_web/log-viewer/src/components/log-list/log-list.styles.ts b/pw_web/log-viewer/src/components/log-list/log-list.styles.ts index a10b36360..899f3d1eb 100644 --- a/pw_web/log-viewer/src/components/log-list/log-list.styles.ts +++ b/pw_web/log-viewer/src/components/log-list/log-list.styles.ts @@ -40,10 +40,10 @@ export const styles = css` table { border-collapse: collapse; - display: block; + contain: content; + display: table; height: 100%; table-layout: fixed; - width: 100%; } thead, @@ -62,11 +62,12 @@ export const styles = css` tr { border-bottom: 1px solid var(--sys-log-viewer-color-table-cell-outline); + contain: content; display: grid; grid-template-columns: var(--column-widths); justify-content: flex-start; width: 100%; - will-change: transform; + will-change: transform, grid-template-columns; } .log-row--warning { diff --git a/pw_web/log-viewer/src/components/log-list/log-list.ts b/pw_web/log-viewer/src/components/log-list/log-list.ts index 58d635ce7..18c976685 100644 --- a/pw_web/log-viewer/src/components/log-list/log-list.ts +++ b/pw_web/log-viewer/src/components/log-list/log-list.ts @@ -25,6 +25,7 @@ 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'; +import { throttle } from '../../utils/throttle'; /** * A sub-component of the log view which takes filtered logs and renders them in @@ -75,6 +76,9 @@ export class LogList extends LitElement { @query('tbody') private _tableBody!: HTMLTableSectionElement; @queryAll('tr') private _tableRows!: HTMLTableRowElement[]; + /** The zoom level based on pixel ratio of the window */ + private _zoomLevel: number = Math.round(window.devicePixelRatio * 100); + /** Indicates whether to enable autosizing of incoming log entries. */ private _autosizeLocked = false; @@ -297,20 +301,28 @@ export class LogList extends LitElement { this.lastScrollTop = currentScrollTop; const logsAreCleared = this.logs.length == 0; + const zoomChanged = + this._zoomLevel !== Math.round(window.devicePixelRatio * 100); if (logsAreCleared) { this._autoscrollIsEnabled = true; return; } - // Run autoscroll logic if scrolling vertically + // Do not change autoscroll if zoom level on window changed + if (zoomChanged) { + this._zoomLevel = Math.round(window.devicePixelRatio * 100); + return; + } + + // Calculate horizontal scroll percentage if (!isScrollingVertically) { this._scrollPercentageLeft = scrollLeft / maxScrollLeft || 0; return; } // Scroll direction up, disable autoscroll - if (isScrollingUp) { + if (isScrollingUp && Math.abs(scrollY) > 1) { this._autoscrollIsEnabled = false; return; } @@ -363,9 +375,9 @@ export class LogList extends LitElement { startWidth, }; - const handleColumnResize = (event: MouseEvent) => { + const handleColumnResize = throttle((event: MouseEvent) => { this.handleColumnResize(event); - }; + }, 32); const handleColumnResizeEnd = () => { this.columnResizeData = null; diff --git a/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.ts b/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.ts index 8c0226f7b..4e250314b 100644 --- a/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.ts +++ b/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.ts @@ -49,8 +49,9 @@ export class LogViewControls extends LitElement { @state() _stateStore: StateStore = new LocalStorageState(); - @state() - _viewTitle = 'Log View'; + /** The title of the parent log view, to be displayed on the log view toolbar */ + @property() + viewTitle = ''; @state() _moreActionsMenuOpen = false; @@ -85,9 +86,9 @@ export class LogViewControls extends LitElement { for (const i in viewConfigArr) { if (viewConfigArr[i].viewID === this.viewId) { searchText = viewConfigArr[i].search as string; - this._viewTitle = viewConfigArr[i].viewTitle + this.viewTitle = viewConfigArr[i].viewTitle ? viewConfigArr[i].viewTitle - : this._viewTitle; + : this.viewTitle; } } } @@ -231,7 +232,7 @@ export class LogViewControls extends LitElement { composed: true, detail: { format: 'plaintext', - viewTitle: this._viewTitle, + viewTitle: this.viewTitle, }, }); @@ -250,7 +251,7 @@ export class LogViewControls extends LitElement { render() { return html` - <p class="host-name">${this._viewTitle}</p> + <p class="host-name">${this.viewTitle}</p> <div class="input-container"> <div class="input-facade" contenteditable="plaintext-only" @input="${ diff --git a/pw_web/log-viewer/src/components/log-view/log-view.ts b/pw_web/log-viewer/src/components/log-view/log-view.ts index 900637e5c..f96a66df7 100644 --- a/pw_web/log-viewer/src/components/log-view/log-view.ts +++ b/pw_web/log-viewer/src/components/log-view/log-view.ts @@ -16,7 +16,12 @@ import { LitElement, PropertyValues, html } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { styles } from './log-view.styles'; import { LogList } from '../log-list/log-list'; -import { TableColumn, LogEntry, State } from '../../shared/interfaces'; +import { + TableColumn, + LogEntry, + State, + SourceData, +} from '../../shared/interfaces'; import { LocalStorageState, StateStore } from '../../shared/state'; import { LogFilter } from '../../utils/log-filter/log-filter'; import '../log-list/log-list'; @@ -51,6 +56,10 @@ export class LogView extends LitElement { @property({ type: Boolean }) isOneOfMany = false; + /** The title of the log view, to be displayed on the log view toolbar */ + @property() + viewTitle = ''; + /** Whether line wrapping in table cells should be used. */ @state() _lineWrap = false; @@ -69,6 +78,9 @@ export class LogView extends LitElement { @query('log-list') _logList!: LogList; + /** A map containing data from present log sources */ + sources: Map<string, SourceData> = new Map(); + /** * An array containing the logs that remain after the current filter has been * applied. @@ -108,6 +120,14 @@ export class LogView extends LitElement { const storedColumnData = viewConfigArr[index].columnData; this._columnData = storedColumnData; } + + // Update view title with log source names if a view title isn't already provided + if (!this.viewTitle) { + const sourceNames = Array.from(this.sources.values())?.map( + (tag: SourceData) => tag.name, + ); + this.viewTitle = sourceNames.join(', '); + } } updated(changedProperties: PropertyValues) { @@ -328,6 +348,7 @@ export class LogView extends LitElement { return html` <log-view-controls .columnData=${this._columnData} .viewId=${this.id} + .viewTitle=${this.viewTitle} .hideCloseButton=${!this.isOneOfMany} .stateStore=${this._stateStore} @input-change="${this.updateFilter}" diff --git a/pw_web/log-viewer/src/components/log-viewer.ts b/pw_web/log-viewer/src/components/log-viewer.ts index 06c695309..73ad808cd 100644 --- a/pw_web/log-viewer/src/components/log-viewer.ts +++ b/pw_web/log-viewer/src/components/log-viewer.ts @@ -20,8 +20,9 @@ import { LogEntry, LogViewConfig, State, + SourceData, } from '../shared/interfaces'; -import { StateStore } from '../shared/state'; +import { LocalStorageState, StateStore } from '../shared/state'; import { styles } from './log-viewer.styles'; import { themeDark } from '../themes/dark'; import { themeLight } from '../themes/light'; @@ -55,9 +56,12 @@ export class LogViewer extends LitElement { @state() _stateStore: StateStore; + /** A map containing data from present log sources */ + private _sources: Map<string, SourceData> = new Map(); + private _state: State; - constructor(state: StateStore) { + constructor(state: StateStore = new LocalStorageState()) { super(); this._stateStore = state; this._state = this._stateStore.getState(); @@ -106,6 +110,14 @@ export class LogViewer extends LitElement { localStorage.removeItem('colorScheme'); } } + + if (changedProperties.has('logs')) { + this.logs.forEach((logEntry) => { + if (logEntry.sourceData && !this._sources.has(logEntry.sourceData.id)) { + this._sources.set(logEntry.sourceData.id, logEntry.sourceData); + } + }); + } } disconnectedCallback() { @@ -143,7 +155,7 @@ export class LogViewer extends LitElement { columnData: fieldColumns, search: '', viewID: view.id, - viewTitle: 'Log View', + viewTitle: '', }; return obj as LogViewConfig; @@ -175,6 +187,7 @@ export class LogViewer extends LitElement { <log-view id=${view.id} .logs=${this.logs} + .sources=${this._sources} .isOneOfMany=${this._logViews.length > 1} .stateStore=${this._stateStore} @add-view="${this.addLogView}" diff --git a/pw_web/log-viewer/src/createLogViewer.ts b/pw_web/log-viewer/src/createLogViewer.ts index 10ce61859..2f5a9ab2f 100644 --- a/pw_web/log-viewer/src/createLogViewer.ts +++ b/pw_web/log-viewer/src/createLogViewer.ts @@ -14,8 +14,9 @@ import { LogViewer as RootComponent } from './components/log-viewer'; import { StateStore, LocalStorageState } from './shared/state'; -import { LogEntry } from '../src/shared/interfaces'; +import { LogSourceEvent } from '../src/shared/interfaces'; import { LogSource } from '../src/log-source'; +import { LogStore } from './log-store'; import '@material/web/button/filled-button.js'; import '@material/web/button/outlined-button.js'; @@ -29,35 +30,45 @@ import '@material/web/menu/menu.js'; import '@material/web/menu/menu-item.js'; export function createLogViewer( - logSource: LogSource, root: HTMLElement, state: StateStore = new LocalStorageState(), + logStore: LogStore, + ...logSources: LogSource[] ) { const logViewer = new RootComponent(state); - const logs: LogEntry[] = []; root.appendChild(logViewer); let lastUpdateTimeoutId: NodeJS.Timeout; - // Define an event listener for the 'logEntry' event - const logEntryListener = (logEntry: LogEntry) => { - logs.push(logEntry); - logViewer.logs = logs; - if (lastUpdateTimeoutId) { - clearTimeout(lastUpdateTimeoutId); - } + const logEntryListener = (event: LogSourceEvent) => { + if (event.type === 'log-entry') { + const logEntry = event.data; + logStore.addLogEntry(logEntry); + logViewer.logs = logStore.getLogs(); + if (lastUpdateTimeoutId) { + clearTimeout(lastUpdateTimeoutId); + } - // Call requestUpdate at most once every 100 milliseconds. - lastUpdateTimeoutId = setTimeout(() => { - const updatedLogs = [...logs]; - logViewer.logs = updatedLogs; - }, 100); + // Call requestUpdate at most once every 100 milliseconds. + lastUpdateTimeoutId = setTimeout(() => { + const updatedLogs = [...logStore.getLogs()]; + logViewer.logs = updatedLogs; + }, 100); + } }; - // Add the event listener to the LogSource instance - logSource.addEventListener('logEntry', logEntryListener); + logSources.forEach((logSource: LogSource) => { + // Add the event listener to the LogSource instance + logSource.addEventListener('log-entry', logEntryListener); + }); // Method to destroy and unsubscribe return () => { - logSource.removeEventListener('logEntry', logEntryListener); + if (logViewer.parentNode) { + logViewer.parentNode.removeChild(logViewer); + } + + logSources.forEach((logSource: LogSource) => { + logSource.removeEventListener('log-entry', logEntryListener); + }); }; } diff --git a/pw_web/log-viewer/src/custom/json-log-source.ts b/pw_web/log-viewer/src/custom/json-log-source.ts index 8914b1bc9..25b3ee1de 100644 --- a/pw_web/log-viewer/src/custom/json-log-source.ts +++ b/pw_web/log-viewer/src/custom/json-log-source.ts @@ -13,7 +13,7 @@ // the License. import { LogSource } from '../log-source'; -import { LogEntry, FieldData, Severity } from '../shared/interfaces'; +import { LogEntry, Field, Severity } from '../shared/interfaces'; import log_data from './log_data.json'; @@ -46,8 +46,8 @@ export class JsonLogSource extends LogSource { 'time', ]; - constructor() { - super(); + constructor(sourceName: string = 'JSON Log Source') { + super(sourceName); } start(): void { @@ -64,7 +64,7 @@ export class JsonLogSource extends LogSource { const readLogEntry = () => { const logEntry = this.readLogEntryFromJson(); - this.emitEvent('logEntry', logEntry); + this.publishLogEntry(logEntry); const nextInterval = getInterval(); setTimeout(readLogEntry, nextInterval); @@ -102,7 +102,7 @@ export class JsonLogSource extends LogSource { ); host_log_time.setUTCMilliseconds(host_log_epoch_milliseconds); - const fields: Array<FieldData> = [ + const fields: Array<Field> = [ { key: 'severity', value: this.logLevelToSeverity[data.levelno] }, { key: 'time', value: host_log_time }, ]; diff --git a/pw_web/log-viewer/src/custom/mock-log-source.ts b/pw_web/log-viewer/src/custom/mock-log-source.ts index 3f7afb96b..a6c792d6d 100644 --- a/pw_web/log-viewer/src/custom/mock-log-source.ts +++ b/pw_web/log-viewer/src/custom/mock-log-source.ts @@ -18,8 +18,8 @@ import { LogEntry, Severity } from '../shared/interfaces'; export class MockLogSource extends LogSource { private intervalId: NodeJS.Timeout | null = null; - constructor() { - super(); + constructor(sourceName: string = 'Mock Log Source') { + super(sourceName); } start(): void { @@ -29,7 +29,7 @@ export class MockLogSource extends LogSource { const readLogEntry = () => { const logEntry = this.readLogEntryFromHost(); - this.emitEvent('logEntry', logEntry); + this.publishLogEntry(logEntry); const nextInterval = getRandomInterval(); setTimeout(readLogEntry, nextInterval); diff --git a/pw_web/log-viewer/src/index.css b/pw_web/log-viewer/src/index.css index 04a79a2c2..68fa823a3 100644 --- a/pw_web/log-viewer/src/index.css +++ b/pw_web/log-viewer/src/index.css @@ -14,7 +14,14 @@ * the License. */ -@import url('https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,300;8..144,400;8..144,500;8..144,600&family=Roboto+Mono:wght@400;500&family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block'); +@import url('https://fonts.googleapis.com/css2?family=Roboto+Flex&family=Roboto+Mono:wght@400;500&display=block'); + +@font-face { + font-family: 'Material Symbols Rounded'; + font-style: normal; + font-display: block; + src: url('./assets/material_symbols_rounded_subset.woff2') format('woff2'); +} :root { background-color: #fff; @@ -26,6 +33,14 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -webkit-text-size-adjust: 100%; + /* Material component properties */ + --md-icon-font: 'Material Symbols Rounded'; + --md-icon-font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 200, 'opsz' 58; + --md-icon-size: 1.25rem; + --md-filled-button-label-text-type: "Roboto Flex", Arial, sans-serif; + --md-outlined-button-label-text-type: "Roboto Flex", Arial, sans-serif; + --md-icon-button-unselected-icon-color: var(--md-sys-color-on-surface-variant); + --md-icon-button-unselected-hover-icon-color: var(--md-sys-color-on-primary-container); } @media (prefers-color-scheme: dark) { diff --git a/pw_web/log-viewer/src/index.ts b/pw_web/log-viewer/src/index.ts index b50399561..d61917152 100644 --- a/pw_web/log-viewer/src/index.ts +++ b/pw_web/log-viewer/src/index.ts @@ -14,15 +14,24 @@ import { JsonLogSource } from './custom/json-log-source'; import { createLogViewer } from './createLogViewer'; +import { MockLogSource } from './custom/mock-log-source'; +import { LocalStorageState } from './shared/state'; +import { LogSource } from './log-source'; +import { LogStore } from './log-store'; + +const logStore = new LogStore(); +const logSources = [new MockLogSource(), new JsonLogSource()] as LogSource[]; +const state = new LocalStorageState(); -const logSource = new JsonLogSource(); const containerEl = document.querySelector( '#log-viewer-container', ) as HTMLElement; if (containerEl) { - createLogViewer(logSource, containerEl); + createLogViewer(containerEl, state, logStore, ...logSources); } // Start reading log data -logSource.start(); +logSources.forEach((logSource: LogSource) => { + logSource.start(); +}); diff --git a/pw_web/log-viewer/src/log-source.ts b/pw_web/log-viewer/src/log-source.ts index d000a46d0..6d46f54f8 100644 --- a/pw_web/log-viewer/src/log-source.ts +++ b/pw_web/log-viewer/src/log-source.ts @@ -12,28 +12,43 @@ // License for the specific language governing permissions and limitations under // the License. -import { LogEntry } from './shared/interfaces'; +import { + Field, + LogEntry, + LogSourceEvent, + SourceData, +} from './shared/interfaces'; export abstract class LogSource { private eventListeners: { eventType: string; - listener: (data: LogEntry) => void; + listener: (event: LogSourceEvent) => void; }[]; - constructor() { + protected sourceId: string; + + protected sourceName: string; + + constructor(sourceName: string) { this.eventListeners = []; + this.sourceId = crypto.randomUUID(); + this.sourceName = sourceName; } + abstract start(): void; + + abstract stop(): void; + addEventListener( eventType: string, - listener: (data: LogEntry) => void, + listener: (event: LogSourceEvent) => void, ): void { this.eventListeners.push({ eventType, listener }); } removeEventListener( eventType: string, - listener: (data: LogEntry) => void, + listener: (event: LogSourceEvent) => void, ): void { this.eventListeners = this.eventListeners.filter( (eventListener) => @@ -42,11 +57,72 @@ export abstract class LogSource { ); } - emitEvent(eventType: string, data: LogEntry): void { + emitEvent(event: LogSourceEvent): void { this.eventListeners.forEach((eventListener) => { - if (eventListener.eventType === eventType) { - eventListener.listener(data); + if (eventListener.eventType === event.type) { + eventListener.listener(event); } }); } + + publishLogEntry(logEntry: LogEntry): void { + // Validate the log entry + const validationResult = this.validateLogEntry(logEntry); + if (validationResult !== null) { + console.error('Validation error:', validationResult); + return; + } + + const sourceData: SourceData = { id: this.sourceId, name: this.sourceName }; + logEntry.sourceData = sourceData; + + // Add the name of the log source as a field in the log entry + const logSourceField: Field = { key: 'log_source', value: this.sourceName }; + logEntry.fields.splice(1, 0, logSourceField); + + this.emitEvent({ type: 'log-entry', data: logEntry }); + } + + validateLogEntry(logEntry: LogEntry): string | null { + try { + if (!logEntry.timestamp) { + return 'Log entry has no valid timestamp'; + } + if (!Array.isArray(logEntry.fields)) { + return 'Log entry fields must be an array'; + } + if (logEntry.fields.length === 0) { + return 'Log entry fields must not be empty'; + } + + for (const field of logEntry.fields) { + if (!field.key || typeof field.key !== 'string') { + return 'Invalid field key'; + } + if ( + field.value === undefined || + (typeof field.value !== 'string' && + typeof field.value !== 'boolean' && + typeof field.value !== 'number' && + typeof field.value !== 'object') + ) { + return 'Invalid field value'; + } + } + + if ( + logEntry.severity !== undefined && + typeof logEntry.severity !== 'string' + ) { + return 'Invalid severity value'; + } + + return null; + } catch (error) { + if (error instanceof Error) { + console.error('Validation error:', error.message); + } + return 'An unexpected error occurred during validation'; + } + } } diff --git a/pw_web/log-viewer/src/log-store.ts b/pw_web/log-viewer/src/log-store.ts new file mode 100644 index 000000000..fc64b6b20 --- /dev/null +++ b/pw_web/log-viewer/src/log-store.ts @@ -0,0 +1,69 @@ +// 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 { LogEntry } from './shared/interfaces'; +import { titleCaseToKebabCase } from './utils/strings'; + +export class LogStore { + private logs: LogEntry[]; + + constructor() { + this.logs = []; + } + + addLogEntry(logEntry: LogEntry) { + this.logs.push(logEntry); + } + + downloadLogs(event: CustomEvent) { + const logs = this.getLogs(); + const headers = logs[0]?.fields.map((field) => field.key) || []; + const maxWidths = headers.map((header) => header.length); + const viewTitle = event.detail.viewTitle; + const fileName = viewTitle ? titleCaseToKebabCase(viewTitle) : 'logs'; + + logs.forEach((log) => { + log.fields.forEach((field, columnIndex) => { + maxWidths[columnIndex] = Math.max( + maxWidths[columnIndex], + field.value.toString().length, + ); + }); + }); + + const headerRow = headers + .map((header, columnIndex) => header.padEnd(maxWidths[columnIndex])) + .join('\t'); + const separator = ''; + const logRows = logs.map((log) => { + const values = log.fields.map((field, columnIndex) => + field.value.toString().padEnd(maxWidths[columnIndex]), + ); + return values.join('\t'); + }); + + const formattedLogs = [headerRow, separator, ...logRows].join('\n'); + const blob = new Blob([formattedLogs], { type: 'text/plain' }); + const downloadLink = document.createElement('a'); + downloadLink.href = URL.createObjectURL(blob); + downloadLink.download = `${fileName}.txt`; + downloadLink.click(); + + URL.revokeObjectURL(downloadLink.href); + } + + getLogs(): LogEntry[] { + return this.logs; + } +} diff --git a/pw_web/log-viewer/src/shared/interfaces.ts b/pw_web/log-viewer/src/shared/interfaces.ts index 2b1ac5946..de0b3d772 100644 --- a/pw_web/log-viewer/src/shared/interfaces.ts +++ b/pw_web/log-viewer/src/shared/interfaces.ts @@ -12,7 +12,7 @@ // License for the specific language governing permissions and limitations under // the License. -export interface FieldData { +export interface Field { key: string; value: string | boolean | number | object; } @@ -27,7 +27,8 @@ export interface TableColumn { export interface LogEntry { severity?: Severity; timestamp: Date; - fields: FieldData[]; + fields: Field[]; + sourceData?: SourceData; } export interface LogViewConfig { @@ -48,3 +49,16 @@ export enum Severity { export interface State { logViewConfig: LogViewConfig[]; } + +export interface LogEntryEvent { + type: 'log-entry'; + data: LogEntry; +} + +// Union type for all log source event types +export type LogSourceEvent = LogEntryEvent /* | ... */; + +export interface SourceData { + id: string; + name: string; +} diff --git a/pw_web/log-viewer/src/utils/debounce.ts b/pw_web/log-viewer/src/utils/debounce.ts new file mode 100644 index 000000000..3d0bfab5d --- /dev/null +++ b/pw_web/log-viewer/src/utils/debounce.ts @@ -0,0 +1,30 @@ +// Copyright 2024 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. + +export function debounce<T extends unknown[]>( + func: (...args: T) => void, + wait: number, +): (...args: T) => void { + let timeout: ReturnType<typeof setTimeout> | null = null; + return function (...args: T) { + const later = () => { + timeout = null; + func(...args); + }; + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(later, wait); + }; +} diff --git a/pw_web/log-viewer/src/utils/log-filter/log-filter.ts b/pw_web/log-viewer/src/utils/log-filter/log-filter.ts index 344bba790..39a86b1fb 100644 --- a/pw_web/log-viewer/src/utils/log-filter/log-filter.ts +++ b/pw_web/log-viewer/src/utils/log-filter/log-filter.ts @@ -190,15 +190,15 @@ export class LogFilter { column: string, value?: string, ): boolean { - const fieldData = logEntry.fields.find((field) => field.key === column); - if (!fieldData) return false; + const field = logEntry.fields.find((field) => field.key === column); + if (!field) return false; if (value === undefined) { return true; } const searchRegex = new RegExp(value, 'i'); - return searchRegex.test(fieldData.value.toString()); + return searchRegex.test(field.value.toString()); } /** diff --git a/pw_web/log-viewer/src/utils/log-filter/log-filter_test.ts b/pw_web/log-viewer/src/utils/log-filter/log-filter_test.ts index 63faa39a0..f942cde09 100644 --- a/pw_web/log-viewer/src/utils/log-filter/log-filter_test.ts +++ b/pw_web/log-viewer/src/utils/log-filter/log-filter_test.ts @@ -14,7 +14,6 @@ import { LogFilter } from './log-filter'; import { Severity, LogEntry } from '../../shared/interfaces'; -import { describe, expect, test } from '@jest/globals'; import testData from './test-data'; describe('LogFilter', () => { diff --git a/pw_web/log-viewer/src/utils/throttle.ts b/pw_web/log-viewer/src/utils/throttle.ts new file mode 100644 index 000000000..7d325b685 --- /dev/null +++ b/pw_web/log-viewer/src/utils/throttle.ts @@ -0,0 +1,47 @@ +// Copyright 2024 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. + +export function throttle<T extends unknown[]>( + func: (...args: T) => void, + wait: number, +): (...args: T) => void { + let timeout: ReturnType<typeof setTimeout> | null = null; + let lastArgs: T | null = null; + let lastCallTime: number | null = null; + + const invokeFunction = (args: T) => { + lastCallTime = Date.now(); + func(...args); + timeout = null; + }; + + return function (...args: T) { + const now = Date.now(); + const remainingWait = lastCallTime ? wait - (now - lastCallTime) : 0; + + lastArgs = args; + + if (remainingWait <= 0 || remainingWait > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + invokeFunction(args); + } else if (!timeout) { + timeout = setTimeout(() => { + invokeFunction(lastArgs as T); + }, remainingWait); + } + }; +} |