aboutsummaryrefslogtreecommitdiff
path: root/pw_web/log-viewer/src
diff options
context:
space:
mode:
Diffstat (limited to 'pw_web/log-viewer/src')
-rw-r--r--pw_web/log-viewer/src/assets/material_symbols_rounded_subset.woff2bin0 -> 12464 bytes
-rw-r--r--pw_web/log-viewer/src/components/log-list/log-list.styles.ts7
-rw-r--r--pw_web/log-viewer/src/components/log-list/log-list.ts20
-rw-r--r--pw_web/log-viewer/src/components/log-view-controls/log-view-controls.ts13
-rw-r--r--pw_web/log-viewer/src/components/log-view/log-view.ts23
-rw-r--r--pw_web/log-viewer/src/components/log-viewer.ts19
-rw-r--r--pw_web/log-viewer/src/createLogViewer.ts47
-rw-r--r--pw_web/log-viewer/src/custom/json-log-source.ts10
-rw-r--r--pw_web/log-viewer/src/custom/mock-log-source.ts6
-rw-r--r--pw_web/log-viewer/src/index.css17
-rw-r--r--pw_web/log-viewer/src/index.ts15
-rw-r--r--pw_web/log-viewer/src/log-source.ts92
-rw-r--r--pw_web/log-viewer/src/log-store.ts69
-rw-r--r--pw_web/log-viewer/src/shared/interfaces.ts18
-rw-r--r--pw_web/log-viewer/src/utils/debounce.ts30
-rw-r--r--pw_web/log-viewer/src/utils/log-filter/log-filter.ts6
-rw-r--r--pw_web/log-viewer/src/utils/log-filter/log-filter_test.ts1
-rw-r--r--pw_web/log-viewer/src/utils/throttle.ts47
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
new file mode 100644
index 000000000..415bc318b
--- /dev/null
+++ b/pw_web/log-viewer/src/assets/material_symbols_rounded_subset.woff2
Binary files differ
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);
+ }
+ };
+}