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/favicon.svg97
-rw-r--r--pw_web/log-viewer/src/components/log-list/log-list.styles.ts327
-rw-r--r--pw_web/log-viewer/src/components/log-list/log-list.ts588
-rw-r--r--pw_web/log-viewer/src/components/log-view-controls/log-view-controls.styles.ts114
-rw-r--r--pw_web/log-viewer/src/components/log-view-controls/log-view-controls.ts354
-rw-r--r--pw_web/log-viewer/src/components/log-view/log-view.styles.ts27
-rw-r--r--pw_web/log-viewer/src/components/log-view/log-view.ts352
-rw-r--r--pw_web/log-viewer/src/components/log-viewer.styles.ts68
-rw-r--r--pw_web/log-viewer/src/components/log-viewer.ts193
-rw-r--r--pw_web/log-viewer/src/createLogViewer.ts63
-rw-r--r--pw_web/log-viewer/src/custom/json-log-source.ts131
-rw-r--r--pw_web/log-viewer/src/custom/log_data.json37
-rw-r--r--pw_web/log-viewer/src/custom/mock-log-source.ts132
-rw-r--r--pw_web/log-viewer/src/events/add-view.ts23
-rw-r--r--pw_web/log-viewer/src/events/clear-logs.ts27
-rw-r--r--pw_web/log-viewer/src/events/close-view.ts27
-rw-r--r--pw_web/log-viewer/src/events/column-toggle.ts28
-rw-r--r--pw_web/log-viewer/src/events/download-logs.ts28
-rw-r--r--pw_web/log-viewer/src/events/input-change.ts27
-rw-r--r--pw_web/log-viewer/src/events/wrap-toggle.ts27
-rw-r--r--pw_web/log-viewer/src/index.css71
-rw-r--r--pw_web/log-viewer/src/index.ts28
-rw-r--r--pw_web/log-viewer/src/log-source.ts52
-rw-r--r--pw_web/log-viewer/src/shared/interfaces.ts50
-rw-r--r--pw_web/log-viewer/src/shared/state.ts42
-rw-r--r--pw_web/log-viewer/src/themes/dark.ts211
-rw-r--r--pw_web/log-viewer/src/themes/light.ts213
-rw-r--r--pw_web/log-viewer/src/utils/log-filter/log-filter.models.ts61
-rw-r--r--pw_web/log-viewer/src/utils/log-filter/log-filter.ts254
-rw-r--r--pw_web/log-viewer/src/utils/log-filter/log-filter_test.ts173
-rw-r--r--pw_web/log-viewer/src/utils/log-filter/test-data.ts361
-rw-r--r--pw_web/log-viewer/src/utils/strings.ts22
32 files changed, 4208 insertions, 0 deletions
diff --git a/pw_web/log-viewer/src/assets/favicon.svg b/pw_web/log-viewer/src/assets/favicon.svg
new file mode 100644
index 000000000..4ebaf2a48
--- /dev/null
+++ b/pw_web/log-viewer/src/assets/favicon.svg
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="82.519623"
+ height="96"
+ viewBox="0 0 21.833318 25.4"
+ version="1.1"
+ id="svg5"
+ inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
+ sodipodi:docname="pw-logo.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview7"
+ pagecolor="#ffffff"
+ bordercolor="#000000"
+ borderopacity="0.25"
+ inkscape:showpageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:deskcolor="#d1d1d1"
+ inkscape:document-units="px"
+ showgrid="true"
+ inkscape:zoom="5.1754899"
+ inkscape:cx="34.29627"
+ inkscape:cy="64.824781"
+ inkscape:window-width="2131"
+ inkscape:window-height="1334"
+ inkscape:window-x="1307"
+ inkscape:window-y="72"
+ inkscape:window-maximized="0"
+ inkscape:current-layer="layer1"
+ inkscape:pageshadow="2"
+ fit-margin-top="0"
+ fit-margin-left="0.25981"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ width="96px">
+ <inkscape:grid
+ type="xygrid"
+ id="grid899"
+ originx="-0.29938562"
+ originy="0.007612793" />
+ </sodipodi:namedview>
+ <defs
+ id="defs2" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-0.29938553,0.00761281)">
+ <g
+ id="g934"
+ transform="matrix(6.0036869,0,0,6.0036869,-1.8419922,0.03809209)">
+ <path
+ style="fill:#f100f7;fill-opacity:1;stroke-width:0.216574"
+ d="M 2.1616449,3.4336061 C 1.9091824,3.5326629 1.5875433,1.5193339 1.8309272,0.85077532 1.9385896,0.55503414 2.031071,0.2463175 2.4114224,-0.00401171 2.8544419,-0.05203268 2.6565454,0.38084903 2.5067113,1.256888 2.3568774,2.1329272 2.1616453,3.4336062 2.1616449,3.4336061 Z"
+ id="path2515"
+ sodipodi:nodetypes="cscsc" />
+ <path
+ style="fill:#b700d7;fill-opacity:1;stroke-width:0.216574"
+ d="M 2.2191106,3.4109307 C 2.0678012,3.6359974 2.0167095,3.0174321 1.7528477,2.3566876 1.4889859,1.6959431 0.94922328,2.1927191 1.0001916,1.7425692 1.1506181,1.5574268 1.8838262,1.5874003 2.2588512,2.3931613 2.6338765,3.1989223 2.219111,3.4109305 2.2191106,3.4109307 Z"
+ id="path2413"
+ sodipodi:nodetypes="cscsc" />
+ <path
+ style="fill:#951798;fill-opacity:1;stroke-width:0.216574"
+ d="M 2.1237722,3.5148295 C 2.3150351,3.7071003 2.3599863,3.0564818 2.6763817,2.4192218 2.9059426,1.9568575 3.3825927,1.9923461 3.1850672,1.6011417 2.9975756,1.4621979 2.4269145,1.8007494 2.2115779,2.6630282 1.9962408,3.525307 2.1237717,3.5148295 2.1237722,3.5148295 Z"
+ id="path2513"
+ sodipodi:nodetypes="cscsc"
+ inkscape:transform-center-x="-0.045186222"
+ inkscape:transform-center-y="-0.11748418" />
+ <path
+ style="fill:#b700d7;fill-opacity:1;stroke-width:0.216574"
+ d="M 2.16657,3.1630056 C 1.935386,3.3047925 2.3927779,2.3796051 2.1943537,1.717073 2.0826164,1.3439858 1.8924319,1.040618 2.0299315,0.81735074 2.252724,0.72377787 2.5506558,1.2084909 2.5567396,2.0972302 2.5628236,2.9859696 2.1665704,3.1630056 2.16657,3.1630056 Z"
+ id="path2517"
+ sodipodi:nodetypes="cscsc" />
+ <path
+ style="fill:#fb71fe;fill-opacity:1;stroke-width:0.216574"
+ d="M 2.1694107,3.3555634 C 1.972559,3.5421082 1.8165702,2.9074538 1.703537,2.2050082 1.5905038,1.5025626 1.0219277,1.2858513 1.2514214,1.001359 1.637517,0.63319073 1.9169663,1.6332865 2.1067716,2.5015425 c 0.1898056,0.8682561 0.062639,0.8540209 0.062639,0.8540209 z"
+ id="path2519"
+ sodipodi:nodetypes="cscsc" />
+ <path
+ style="fill:#00a100;fill-opacity:1;stroke-width:0.264583"
+ d="M 2.1055809,4.223121 C 2.0551089,3.1942573 2.0383098,2.9291347 2.6324934,2.5000643 3.2266767,2.070994 3.6923741,2.4674508 3.9933288,2.5699226 3.718952,2.7370717 3.4647904,2.6395555 3.1496058,2.781203 2.7064121,2.9803792 2.738338,3.0037867 2.6187891,3.2423382 2.2503826,3.9774682 2.4762183,4.1592185 2.1055809,4.223121 Z"
+ id="path2415"
+ sodipodi:nodetypes="cscssc" />
+ <path
+ style="fill:#00cf00;fill-opacity:1;stroke-width:0.272503"
+ d="M 2.0975886,4.2112405 C 1.7892123,4.1687886 2.0455122,3.5896312 1.4981867,2.9104616 1.0529822,2.3580126 0.74373118,3.1368263 0.36812698,2.8108117 0.69766349,1.9826211 1.4696657,1.6318979 1.8391765,2.643645 c 0.097896,0.2680453 0.1875581,0.3804525 0.2399067,0.5618191 0.1452432,0.5032081 0.2932885,1.0610147 0.018505,1.0057764 z"
+ id="path2411"
+ sodipodi:nodetypes="cscssc" />
+ </g>
+ </g>
+</svg>
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
new file mode 100644
index 000000000..a10b36360
--- /dev/null
+++ b/pw_web/log-viewer/src/components/log-list/log-list.styles.ts
@@ -0,0 +1,327 @@
+// 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 { css } from 'lit';
+
+export const styles = css`
+ * {
+ box-sizing: border-box;
+ }
+
+ :host {
+ background-color: var(--sys-log-viewer-color-table-bg);
+ color: var(--sys-log-viewer-color-table-text);
+ display: block;
+ font-family: 'Roboto Mono', monospace;
+ font-size: 1rem;
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+ }
+
+ .table-container {
+ display: grid;
+ height: 100%;
+ overflow: scroll;
+ scroll-behavior: auto;
+ width: 100%;
+ }
+
+ table {
+ border-collapse: collapse;
+ display: block;
+ height: 100%;
+ table-layout: fixed;
+ width: 100%;
+ }
+
+ thead,
+ th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ }
+
+ thead {
+ background-color: var(--sys-log-viewer-color-table-header-bg);
+ color: var(--sys-log-viewer-color-table-header-text);
+ display: block;
+ width: 100%;
+ }
+
+ tr {
+ border-bottom: 1px solid var(--sys-log-viewer-color-table-cell-outline);
+ display: grid;
+ grid-template-columns: var(--column-widths);
+ justify-content: flex-start;
+ width: 100%;
+ will-change: transform;
+ }
+
+ .log-row--warning {
+ --bg-color: var(--sys-log-viewer-color-surface-yellow);
+ --text-color: var(--sys-log-viewer-color-on-surface-yellow);
+ --icon-color: var(--sys-log-viewer-color-orange-bright);
+ }
+
+ .log-row--error,
+ .log-row--critical {
+ --bg-color: var(--sys-log-viewer-color-surface-error);
+ --text-color: var(--sys-log-viewer-color-on-surface-error);
+ --icon-color: var(--sys-log-viewer-color-error-bright);
+ }
+
+ .log-row--debug {
+ --bg-color: initial;
+ --text-color: var(--sys-log-viewer-color-debug);
+ --icon-color: var(--sys-log-viewer-color-debug);
+ }
+
+ .log-row--warning .cell-icon,
+ .log-row--error .cell-icon,
+ .log-row--critical .cell-icon {
+ color: var(--icon-color);
+ }
+
+ .log-row--warning,
+ .log-row--error,
+ .log-row--critical,
+ .log-row--debug {
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ }
+
+ .log-row .cell-content {
+ display: inline-flex;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: pre-wrap;
+ }
+
+ .log-row--nowrap .cell-content {
+ white-space: pre;
+ }
+
+ tbody tr::before {
+ background-color: transparent;
+ bottom: 0;
+ content: '';
+ display: block;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 100%;
+ z-index: -1;
+ }
+
+ tbody tr:hover::before {
+ background-color: rgba(
+ var(--sys-log-viewer-color-table-row-highlight),
+ 0.05
+ );
+ }
+
+ th,
+ td {
+ display: block;
+ grid-row: 1;
+ overflow: hidden;
+ padding: var(--sys-log-viewer-table-cell-padding);
+ text-align: left;
+ text-overflow: ellipsis;
+ }
+
+ th[hidden],
+ td[hidden] {
+ display: none;
+ }
+
+ th {
+ grid-row: 1;
+ white-space: nowrap;
+ }
+
+ th[title='severity'] {
+ visibility: hidden;
+ }
+
+ td {
+ display: inline-flex;
+ position: relative;
+ vertical-align: top;
+ align-items: flex-start;
+ }
+
+ .cell-text {
+ line-height: normal;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .jump-to-bottom-btn {
+ bottom: 2.25rem;
+ font-family: 'Roboto Flex', sans-serif;
+ position: absolute;
+ place-self: center;
+ transform: translateY(15%) scale(0.9);
+ }
+
+ .resize-handle {
+ background-color: var(--sys-log-viewer-color-table-cell-outline);
+ bottom: 0;
+ content: '';
+ cursor: col-resize;
+ height: 100%;
+ left: 0;
+ mix-blend-mode: luminosity;
+ opacity: 1;
+ pointer-events: auto;
+ position: absolute;
+ right: 0;
+ top: 0;
+ transition: opacity 300ms ease;
+ width: 1px;
+ z-index: 1;
+ }
+
+ .resize-handle:hover {
+ background-color: var(--sys-log-viewer-color-primary);
+ mix-blend-mode: unset;
+ outline: 1px solid var(--sys-log-viewer-color-primary);
+ }
+
+ .resize-handle::before {
+ bottom: 0;
+ content: '';
+ display: block;
+ position: absolute;
+ right: -0.5rem;
+ top: 0;
+ width: 1rem;
+ }
+
+ .cell-icon {
+ display: block;
+ font-variation-settings:
+ 'FILL' 1,
+ 'wght' 400,
+ 'GRAD' 200,
+ 'opsz' 58;
+ font-size: var(--sys-log-viewer-table-cell-icon-size);
+ user-select: none;
+ display: grid;
+ place-content: center;
+ place-items: center;
+ }
+
+ .overflow-indicator {
+ pointer-events: none;
+ position: absolute;
+ width: 8rem;
+ }
+
+ .bottom-indicator {
+ align-self: flex-end;
+ background: linear-gradient(
+ to bottom,
+ transparent,
+ var(--sys-log-viewer-color-overflow-indicator)
+ );
+ height: 8rem;
+ pointer-events: none;
+ position: absolute;
+ width: calc(100% - 1rem);
+ }
+
+ .left-indicator {
+ background: linear-gradient(
+ to left,
+ transparent,
+ var(--sys-log-viewer-color-overflow-indicator)
+ );
+ height: calc(100% - 1rem);
+ justify-self: flex-start;
+ }
+
+ .right-indicator {
+ background: linear-gradient(
+ to right,
+ transparent,
+ var(--sys-log-viewer-color-overflow-indicator)
+ );
+ height: calc(100% - 1rem);
+ justify-self: flex-end;
+ }
+
+ mark {
+ background-color: var(--sys-log-viewer-color-table-mark);
+ border-radius: 4px;
+ color: var(--sys-log-viewer-color-table-mark-text);
+ outline: 1px solid var(--sys-log-viewer-color-table-mark);
+ }
+
+ .jump-to-bottom-btn,
+ .bottom-indicator {
+ opacity: 0;
+ transition:
+ opacity 100ms ease,
+ transform 100ms ease,
+ visibility 100ms ease;
+ visibility: hidden;
+ }
+
+ .jump-to-bottom-btn[data-visible='true'],
+ .bottom-indicator[data-visible='true'] {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ transition:
+ opacity 250ms ease,
+ transform 250ms ease,
+ 250ms ease;
+ visibility: visible;
+ }
+
+ ::-webkit-scrollbar {
+ box-shadow: inset 0 0 2rem 2rem var(--md-sys-color-surface-container-low);
+ -webkit-appearance: auto;
+ }
+
+ ::-webkit-scrollbar-corner {
+ background: var(--md-sys-color-surface-container-low);
+ }
+
+ ::-webkit-scrollbar-thumb {
+ border-radius: 20px;
+ box-shadow: inset 0 0 2rem 2rem var(--md-sys-color-outline-variant);
+ border: inset 3px transparent;
+ }
+
+ ::-webkit-scrollbar-thumb:horizontal {
+ border-top: inset 4px transparent;
+ }
+
+ ::-webkit-scrollbar-thumb:vertical {
+ border-left: inset 4px transparent;
+ height: calc(100% / 3);
+ }
+
+ ::-webkit-scrollbar-track:horizontal {
+ border-top: solid 1px var(--md-sys-color-outline-variant);
+ }
+
+ ::-webkit-scrollbar-track:vertical {
+ border-left: solid 1px var(--md-sys-color-outline-variant);
+ }
+`;
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
new file mode 100644
index 000000000..58d635ce7
--- /dev/null
+++ b/pw_web/log-viewer/src/components/log-list/log-list.ts
@@ -0,0 +1,588 @@
+// 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>
+ `;
+}
diff --git a/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.styles.ts b/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.styles.ts
new file mode 100644
index 000000000..0277857eb
--- /dev/null
+++ b/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.styles.ts
@@ -0,0 +1,114 @@
+// 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 { css } from 'lit';
+
+export const styles = css`
+ :host {
+ align-items: center;
+ background-color: var(--sys-log-viewer-color-controls-bg);
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
+ box-sizing: border-box;
+ color: var(--sys-log-viewer-color-controls-text);
+ display: flex;
+ flex-shrink: 0;
+ gap: 1rem;
+ height: 3rem;
+ justify-content: space-between;
+ padding: 0 1rem;
+ --md-list-item-leading-icon-size: 1.5rem;
+ }
+
+ :host > * {
+ display: flex;
+ }
+
+ .host-name {
+ font-size: 1.125rem;
+ font-weight: 300;
+ margin: 0;
+ white-space: nowrap;
+ }
+
+ .field-menu {
+ background-color: var(--md-sys-color-surface-container);
+ border-radius: 4px;
+ margin: 0;
+ padding: 0.5rem 0.75rem;
+ position: absolute;
+ right: 0;
+ z-index: 2;
+ }
+
+ md-standard-icon-button[selected] {
+ background-color: var(--sys-log-viewer-color-controls-button-enabled);
+ border-radius: 100%;
+ }
+
+ .field-menu-item {
+ align-items: center;
+ display: flex;
+ height: 3rem;
+ width: max-content;
+ }
+
+ .field-toggle {
+ border-radius: 1.5rem;
+ position: relative;
+ }
+
+ .input-container {
+ justify-content: flex-end;
+ width: 100%;
+ }
+
+ .input-facade {
+ align-items: center;
+ background-color: var(--sys-log-viewer-color-controls-input-bg);
+ border: 1px solid var(--sys-log-viewer-color-controls-input-outline);
+ border-radius: 1.5rem;
+ cursor: text;
+ display: inline-flex;
+ font-size: 1rem;
+ height: 0.75rem;
+ line-height: 0.75;
+ max-width: 30rem;
+ overflow: hidden;
+ padding: 0.5rem 1rem;
+ width: 100%;
+ }
+
+ input[type='text'] {
+ display: none;
+ }
+
+ input::placeholder {
+ color: var(--md-sys-color-on-surface-variant);
+ }
+
+ input[type='checkbox'] {
+ accent-color: var(--md-sys-color-primary);
+ height: 1.125rem;
+ width: 1.125rem;
+ }
+
+ label {
+ padding-left: 0.75rem;
+ }
+
+ p {
+ flex: 1 0;
+ white-space: nowrap;
+ }
+`;
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
new file mode 100644
index 000000000..8c0226f7b
--- /dev/null
+++ b/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.ts
@@ -0,0 +1,354 @@
+// 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 } from 'lit';
+import {
+ customElement,
+ property,
+ query,
+ queryAll,
+ state,
+} from 'lit/decorators.js';
+import { styles } from './log-view-controls.styles';
+import { State, TableColumn } from '../../shared/interfaces';
+import { StateStore, LocalStorageState } from '../../shared/state';
+
+/**
+ * A sub-component of the log view with user inputs for managing and customizing
+ * log entry display and interaction.
+ *
+ * @element log-view-controls
+ */
+@customElement('log-view-controls')
+export class LogViewControls extends LitElement {
+ static styles = styles;
+
+ /** The `id` of the parent view containing this log list. */
+ @property({ type: String })
+ viewId = '';
+
+ @property({ type: Array })
+ columnData: TableColumn[] = [];
+
+ /** Indicates whether to enable the button for closing the current log view. */
+ @property({ type: Boolean })
+ hideCloseButton = false;
+
+ /** A StateStore object that stores state of views */
+ @state()
+ _stateStore: StateStore = new LocalStorageState();
+
+ @state()
+ _viewTitle = 'Log View';
+
+ @state()
+ _moreActionsMenuOpen = false;
+
+ @query('.field-menu') _fieldMenu!: HTMLMenuElement;
+
+ @query('#search-field') _searchField!: HTMLInputElement;
+
+ @query('.input-facade') _inputFacade!: HTMLDivElement;
+
+ @queryAll('.item-checkboxes') _itemCheckboxes!: HTMLCollection[];
+
+ private _state: State;
+
+ /** The timer identifier for debouncing search input. */
+ private _inputDebounceTimer: number | null = null;
+
+ /** The delay (in ms) used for debouncing search input. */
+ private readonly INPUT_DEBOUNCE_DELAY = 50;
+
+ @query('.more-actions-button') moreActionsButtonEl!: HTMLElement;
+
+ constructor() {
+ super();
+ this._state = this._stateStore.getState();
+ }
+
+ protected firstUpdated(): void {
+ let searchText = '';
+ if (this._state !== null) {
+ const viewConfigArr = this._state.logViewConfig;
+ for (const i in viewConfigArr) {
+ if (viewConfigArr[i].viewID === this.viewId) {
+ searchText = viewConfigArr[i].search as string;
+ this._viewTitle = viewConfigArr[i].viewTitle
+ ? viewConfigArr[i].viewTitle
+ : this._viewTitle;
+ }
+ }
+ }
+
+ this._inputFacade.textContent = searchText;
+ this._inputFacade.dispatchEvent(new CustomEvent('input'));
+ }
+
+ /**
+ * Called whenever the search field value is changed. Debounces the input
+ * event and dispatches an event with the input value after a specified
+ * delay.
+ *
+ * @param {Event} event - The input event object.
+ */
+ private handleInput = (event: Event) => {
+ if (this._inputDebounceTimer) {
+ clearTimeout(this._inputDebounceTimer);
+ }
+
+ const inputFacade = event.target as HTMLDivElement;
+ this.markKeysInText(inputFacade);
+ this._searchField.value = inputFacade.textContent || '';
+ const inputValue = this._searchField.value;
+
+ this._inputDebounceTimer = window.setTimeout(() => {
+ const customEvent = new CustomEvent('input-change', {
+ detail: { inputValue },
+ bubbles: true,
+ composed: true,
+ });
+
+ this.dispatchEvent(customEvent);
+ }, this.INPUT_DEBOUNCE_DELAY);
+ };
+
+ private markKeysInText(target: HTMLElement) {
+ const pattern = /\b(\w+):(?=\w)/;
+ const textContent = target.textContent || '';
+ const conditions = textContent.split(/\s+/);
+ const wordsBeforeColons: string[] = [];
+
+ for (const condition of conditions) {
+ const match = condition.match(pattern);
+ if (match) {
+ wordsBeforeColons.push(match[0]);
+ }
+ }
+ }
+
+ private handleKeydown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === 'Cmd') {
+ event.preventDefault();
+ }
+ };
+
+ /**
+ * Dispatches a custom event for clearing logs. This event includes a
+ * `timestamp` object indicating the date/time in which the 'clear-logs' event
+ * was dispatched.
+ */
+ private handleClearLogsClick() {
+ const timestamp = new Date();
+
+ const clearLogs = new CustomEvent('clear-logs', {
+ detail: { timestamp },
+ bubbles: true,
+ composed: true,
+ });
+
+ this.dispatchEvent(clearLogs);
+ }
+
+ /** Dispatches a custom event for toggling wrapping. */
+ private handleWrapToggle() {
+ const wrapToggle = new CustomEvent('wrap-toggle', {
+ bubbles: true,
+ composed: true,
+ });
+
+ this.dispatchEvent(wrapToggle);
+ }
+
+ /**
+ * Dispatches a custom event for closing the parent view. This event includes
+ * a `viewId` object indicating the `id` of the parent log view.
+ */
+ private handleCloseViewClick() {
+ const closeView = new CustomEvent('close-view', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ viewId: this.viewId,
+ },
+ });
+
+ this.dispatchEvent(closeView);
+ }
+
+ /**
+ * Dispatches a custom event for showing or hiding a column in the table. This
+ * event includes a `field` string indicating the affected column's field name
+ * and an `isChecked` boolean indicating whether to show or hide the column.
+ *
+ * @param {Event} event - The click event object.
+ */
+ private handleColumnToggle(event: Event) {
+ const inputEl = event.target as HTMLInputElement;
+ const columnToggle = new CustomEvent('column-toggle', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ field: inputEl.value,
+ isChecked: inputEl.checked,
+ },
+ });
+
+ this.dispatchEvent(columnToggle);
+ }
+
+ private handleAddView() {
+ const addView = new CustomEvent('add-view', {
+ bubbles: true,
+ composed: true,
+ });
+
+ this.dispatchEvent(addView);
+ }
+
+ /**
+ * Dispatches a custom event for downloading a logs file. This event includes
+ * a `format` string indicating the format of the file to be downloaded and a
+ * `viewTitle` string which passes the title of the current view for naming
+ * the file.
+ *
+ * @param {Event} event - The click event object.
+ */
+ private handleDownloadLogs() {
+ const downloadLogs = new CustomEvent('download-logs', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ format: 'plaintext',
+ viewTitle: this._viewTitle,
+ },
+ });
+
+ this.dispatchEvent(downloadLogs);
+ }
+
+ /** Opens and closes the column visibility dropdown menu. */
+ private toggleColumnVisibilityMenu() {
+ this._fieldMenu.hidden = !this._fieldMenu.hidden;
+ }
+
+ /** Opens and closes the More Actions menu. */
+ private toggleMoreActionsMenu() {
+ this._moreActionsMenuOpen = !this._moreActionsMenuOpen;
+ }
+
+ render() {
+ return html`
+ <p class="host-name">${this._viewTitle}</p>
+
+ <div class="input-container">
+ <div class="input-facade" contenteditable="plaintext-only" @input="${
+ this.handleInput
+ }" @keydown="${this.handleKeydown}"></div>
+ <input id="search-field" type="text"></input>
+ </div>
+
+ <div class="actions-container">
+ <span class="action-button" hidden>
+ <md-icon-button>
+ <md-icon>pause_circle</md-icon>
+ </md-icon-button>
+ </span>
+
+ <span class="action-button" hidden>
+ <md-icon-button>
+ <md-icon>wrap_text</md-icon>
+ </md-icon-button>
+ </span>
+
+ <span class="action-button" title="Clear logs">
+ <md-icon-button @click=${this.handleClearLogsClick}>
+ <md-icon>delete_sweep</md-icon>
+ </md-icon-button>
+ </span>
+
+ <span class="action-button" title="Toggle Line Wrapping">
+ <md-icon-button @click=${this.handleWrapToggle} toggle>
+ <md-icon>wrap_text</md-icon>
+ </md-icon-button>
+ </span>
+
+ <span class='action-button field-toggle' title="Toggle fields">
+ <md-icon-button @click=${this.toggleColumnVisibilityMenu} toggle>
+ <md-icon>view_column</md-icon>
+ </md-icon-button>
+ <menu class='field-menu' hidden>
+ ${this.columnData.map(
+ (column) => html`
+ <li class="field-menu-item">
+ <input
+ class="item-checkboxes"
+ @click=${this.handleColumnToggle}
+ ?checked=${column.isVisible}
+ type="checkbox"
+ value=${column.fieldName}
+ id=${column.fieldName}
+ />
+ <label for=${column.fieldName}>${column.fieldName}</label>
+ </li>
+ `,
+ )}
+ </menu>
+ </span>
+
+ <span class="action-button" title="Toggle fields">
+ <md-icon-button @click=${
+ this.toggleMoreActionsMenu
+ } class="more-actions-button">
+ <md-icon >more_vert</md-icon>
+ </md-icon-button>
+
+ <md-menu quick fixed
+ ?open=${this._moreActionsMenuOpen}
+ .anchor=${this.moreActionsButtonEl}
+ @closed=${() => {
+ this._moreActionsMenuOpen = false;
+ }}>
+
+ <md-menu-item headline="Add view" @click=${
+ this.handleAddView
+ } role="button" title="Add a view">
+ <md-icon slot="start" data-variant="icon">new_window</md-icon>
+ </md-menu-item>
+
+ <md-menu-item headline="Download logs (.txt)" @click=${
+ this.handleDownloadLogs
+ } role="button" title="Download current logs as a plaintext file">
+ <md-icon slot="start" data-variant="icon">download</md-icon>
+ </md-menu-item>
+ </md-menu>
+ </span>
+
+ <span class="action-button" title="Close view" ?hidden=${
+ this.hideCloseButton
+ }>
+ <md-icon-button @click=${this.handleCloseViewClick}>
+ <md-icon>close</md-icon>
+ </md-icon-button>
+ </span>
+
+ <span class="action-button" hidden>
+ <md-icon-button>
+ <md-icon>more_horiz</md-icon>
+ </md-icon-button>
+ </span>
+ </div>
+ `;
+ }
+}
diff --git a/pw_web/log-viewer/src/components/log-view/log-view.styles.ts b/pw_web/log-viewer/src/components/log-view/log-view.styles.ts
new file mode 100644
index 000000000..43c2eeadb
--- /dev/null
+++ b/pw_web/log-viewer/src/components/log-view/log-view.styles.ts
@@ -0,0 +1,27 @@
+// 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 { css } from 'lit';
+
+export const styles = css`
+ :host {
+ border: var(--sys-log-viewer-view-outline-width) solid
+ var(--sys-log-viewer-color-view-outline);
+ border-radius: var(--sys-log-viewer-view-corner-radius);
+ color: var(--md-sys-color-on-surface);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+`;
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
new file mode 100644
index 000000000..900637e5c
--- /dev/null
+++ b/pw_web/log-viewer/src/components/log-view/log-view.ts
@@ -0,0 +1,352 @@
+// 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, 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 { LocalStorageState, StateStore } from '../../shared/state';
+import { LogFilter } from '../../utils/log-filter/log-filter';
+import '../log-list/log-list';
+import '../log-view-controls/log-view-controls';
+import { titleCaseToKebabCase } from '../../utils/strings';
+
+type FilterFunction = (logEntry: LogEntry) => boolean;
+
+/**
+ * A component that filters and displays incoming log entries in an encapsulated
+ * instance. Each `LogView` contains a log list and a set of log view controls
+ * for configurable viewing of filtered logs.
+ *
+ * @element log-view
+ */
+@customElement('log-view')
+export class LogView extends LitElement {
+ static styles = styles;
+
+ /**
+ * The component's global `id` attribute. This unique value is set whenever a
+ * view is created in a log viewer instance.
+ */
+ @property({ type: String })
+ id = `${this.localName}-${crypto.randomUUID()}`;
+
+ /** An array of log entries to be displayed. */
+ @property({ type: Array })
+ logs: LogEntry[] = [];
+
+ /** Indicates whether this view is one of multiple instances. */
+ @property({ type: Boolean })
+ isOneOfMany = false;
+
+ /** Whether line wrapping in table cells should be used. */
+ @state()
+ _lineWrap = false;
+
+ /** The field keys (column values) for the incoming log entries. */
+ @state()
+ private _columnData: TableColumn[] = [];
+
+ /** A string representing the value contained in the search field. */
+ @state()
+ public searchText = '';
+
+ /** A StateStore object that stores state of views */
+ @state()
+ _stateStore: StateStore = new LocalStorageState();
+
+ @query('log-list') _logList!: LogList;
+
+ /**
+ * An array containing the logs that remain after the current filter has been
+ * applied.
+ */
+ private _filteredLogs: LogEntry[] = [];
+
+ /** A function used for filtering rows that contain a certain substring. */
+ private _stringFilter: FilterFunction = () => true;
+
+ /**
+ * A function used for filtering rows that contain a timestamp within a
+ * certain window.
+ */
+ private _timeFilter: FilterFunction = () => true;
+
+ private _state: State;
+
+ private _debounceTimeout: NodeJS.Timeout | null = null;
+
+ /** The number of elements in the `logs` array since last updated. */
+ private _lastKnownLogLength: number = 0;
+
+ /** The amount of time, in ms, before the filter expression is executed. */
+ private readonly FILTER_DELAY = 100;
+
+ constructor() {
+ super();
+ this._state = this._stateStore.getState();
+ }
+
+ protected firstUpdated(): void {
+ const viewConfigArr = this._state.logViewConfig;
+ const index = viewConfigArr.findIndex((i) => this.id === i.viewID);
+
+ // Get column data from local storage, if it exists
+ if (index !== -1) {
+ const storedColumnData = viewConfigArr[index].columnData;
+ this._columnData = storedColumnData;
+ }
+ }
+
+ updated(changedProperties: PropertyValues) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('logs')) {
+ const newLogs = this.logs.slice(this._lastKnownLogLength);
+ this._lastKnownLogLength = this.logs.length;
+
+ this.updateFieldsFromNewLogs(newLogs);
+ }
+
+ if (changedProperties.has('logs') || changedProperties.has('searchText')) {
+ this.filterLogs();
+ }
+
+ if (changedProperties.has('_columnData')) {
+ this._state = { logViewConfig: this._state.logViewConfig };
+ this._stateStore.setState({
+ logViewConfig: this._state.logViewConfig,
+ });
+ }
+ }
+
+ /**
+ * Updates the log filter based on the provided event type.
+ *
+ * @param {CustomEvent} event - The custom event containing the information to
+ * update the filter.
+ */
+ private updateFilter(event: CustomEvent) {
+ this.searchText = event.detail.inputValue;
+ const logViewConfig = this._state.logViewConfig;
+ const index = logViewConfig.findIndex((i) => this.id === i.viewID);
+
+ switch (event.type) {
+ case 'input-change':
+ if (this._debounceTimeout) {
+ clearTimeout(this._debounceTimeout);
+ }
+
+ if (index !== -1) {
+ logViewConfig[index].search = this.searchText;
+ this._state = { logViewConfig: logViewConfig };
+ this._stateStore.setState({ logViewConfig: logViewConfig });
+ }
+
+ if (!this.searchText) {
+ this._stringFilter = () => true;
+ return;
+ }
+
+ // Run the filter after the timeout delay
+ this._debounceTimeout = setTimeout(() => {
+ const filters = LogFilter.parseSearchQuery(this.searchText).map(
+ (condition) => LogFilter.createFilterFunction(condition),
+ );
+ this._stringFilter =
+ filters.length > 0
+ ? (logEntry: LogEntry) =>
+ filters.some((filter) => filter(logEntry))
+ : () => true;
+
+ this.filterLogs();
+ this.requestUpdate();
+ }, this.FILTER_DELAY);
+ break;
+ case 'clear-logs':
+ this._timeFilter = (logEntry) =>
+ logEntry.timestamp > event.detail.timestamp;
+ break;
+ default:
+ break;
+ }
+
+ this.filterLogs();
+ this.requestUpdate();
+ }
+
+ private updateFieldsFromNewLogs(newLogs: LogEntry[]): void {
+ if (!this._columnData) {
+ this._columnData = [];
+ }
+
+ newLogs.forEach((log) => {
+ log.fields.forEach((field) => {
+ if (!this._columnData.some((col) => col.fieldName === field.key)) {
+ this._columnData.push({
+ fieldName: field.key,
+ characterLength: 0,
+ manualWidth: null,
+ isVisible: true,
+ });
+ }
+ });
+ });
+ }
+
+ public getFields(): string[] {
+ return this._columnData
+ .filter((column) => column.isVisible)
+ .map((column) => column.fieldName);
+ }
+
+ /**
+ * Toggles the visibility of columns in the log list based on the provided
+ * event.
+ *
+ * @param {CustomEvent} event - The click event containing the field being
+ * toggled.
+ */
+ private toggleColumns(event: CustomEvent) {
+ const logViewConfig = this._state.logViewConfig;
+ const index = logViewConfig.findIndex((i) => this.id === i.viewID);
+
+ if (index === -1) {
+ return;
+ }
+
+ // Find the relevant column in _columnData
+ const column = this._columnData.find(
+ (col) => col.fieldName === event.detail.field,
+ );
+
+ if (!column) {
+ return;
+ }
+
+ // Toggle the column's visibility
+ column.isVisible = event.detail.isChecked;
+
+ // Clear the manually-set width of the last visible column
+ const lastVisibleColumn = this._columnData
+ .slice()
+ .reverse()
+ .find((col) => col.isVisible);
+ if (lastVisibleColumn) {
+ lastVisibleColumn.manualWidth = null;
+ }
+
+ // Trigger the change in column data and request an update
+ this._columnData = [...this._columnData];
+ this._logList.requestUpdate();
+ }
+
+ /**
+ * Toggles the wrapping of text in each row.
+ *
+ * @param {CustomEvent} event - The click event.
+ */
+ private toggleWrapping() {
+ this._lineWrap = !this._lineWrap;
+ }
+
+ /**
+ * Combines filter expressions and filters the logs. The filtered
+ * logs are stored in the `_filteredLogs` property.
+ */
+ private filterLogs() {
+ const combinedFilter = (logEntry: LogEntry) =>
+ this._timeFilter(logEntry) && this._stringFilter(logEntry);
+
+ const newFilteredLogs = this.logs.filter(combinedFilter);
+
+ if (
+ JSON.stringify(newFilteredLogs) !== JSON.stringify(this._filteredLogs)
+ ) {
+ this._filteredLogs = newFilteredLogs;
+ }
+ }
+
+ private updateColumnData(event: CustomEvent) {
+ this._columnData = event.detail;
+ }
+
+ /**
+ * Generates a log file in the specified format and initiates its download.
+ *
+ * @param {CustomEvent} event - The click event.
+ */
+ private downloadLogs(event: CustomEvent) {
+ const headers = this.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';
+
+ this.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 = this.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);
+ }
+
+ render() {
+ return html` <log-view-controls
+ .columnData=${this._columnData}
+ .viewId=${this.id}
+ .hideCloseButton=${!this.isOneOfMany}
+ .stateStore=${this._stateStore}
+ @input-change="${this.updateFilter}"
+ @clear-logs="${this.updateFilter}"
+ @column-toggle="${this.toggleColumns}"
+ @wrap-toggle="${this.toggleWrapping}"
+ @download-logs="${this.downloadLogs}"
+ role="toolbar"
+ >
+ </log-view-controls>
+
+ <log-list
+ .columnData=${[...this._columnData]}
+ .lineWrap=${this._lineWrap}
+ .viewId=${this.id}
+ .logs=${this._filteredLogs}
+ .searchText=${this.searchText}
+ @update-column-data="${this.updateColumnData}"
+ >
+ </log-list>`;
+ }
+}
diff --git a/pw_web/log-viewer/src/components/log-viewer.styles.ts b/pw_web/log-viewer/src/components/log-viewer.styles.ts
new file mode 100644
index 000000000..0bf3a88e6
--- /dev/null
+++ b/pw_web/log-viewer/src/components/log-viewer.styles.ts
@@ -0,0 +1,68 @@
+// 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 { css } from 'lit';
+
+export const styles = css`
+ * {
+ box-sizing: border-box;
+ }
+
+ :host {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ height: var(--sys-log-viewer-height);
+ width: 100%;
+
+ /* Material Web properties */
+ --md-icon-font: 'Material Symbols Rounded';
+ --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
+ );
+ --md-filled-button-container-color: var(--sys-log-viewer-color-primary);
+
+ /* Log View */
+ --sys-log-viewer-height: 100%;
+ --sys-log-viewer-view-outline-width: 1px;
+ --sys-log-viewer-view-corner-radius: 0.5rem;
+
+ /* Log List */
+ --sys-log-viewer-table-cell-padding: 0.375rem 0.75rem;
+ --sys-log-viewer-table-cell-icon-size: 1.125rem;
+ }
+
+ .grid-container {
+ display: grid;
+ grid-gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(27rem, 1fr));
+ height: 100%;
+ overflow: hidden;
+ }
+
+ .add-button {
+ width: 8rem;
+ height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+`;
diff --git a/pw_web/log-viewer/src/components/log-viewer.ts b/pw_web/log-viewer/src/components/log-viewer.ts
new file mode 100644
index 000000000..06c695309
--- /dev/null
+++ b/pw_web/log-viewer/src/components/log-viewer.ts
@@ -0,0 +1,193 @@
+// 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, PropertyValues, html } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { repeat } from 'lit/directives/repeat.js';
+import {
+ TableColumn,
+ LogEntry,
+ LogViewConfig,
+ State,
+} from '../shared/interfaces';
+import { StateStore } from '../shared/state';
+import { styles } from './log-viewer.styles';
+import { themeDark } from '../themes/dark';
+import { themeLight } from '../themes/light';
+import { LogView } from './log-view/log-view';
+import CloseViewEvent from '../events/close-view';
+
+type ColorScheme = 'dark' | 'light';
+
+/**
+ * The root component which renders one or more log views for displaying
+ * structured log entries.
+ *
+ * @element log-viewer
+ */
+@customElement('log-viewer')
+export class LogViewer extends LitElement {
+ static styles = [styles, themeDark, themeLight];
+
+ /** An array of log entries to be displayed. */
+ @property({ type: Array })
+ logs: LogEntry[] = [];
+
+ @property({ type: String, reflect: true })
+ colorScheme?: ColorScheme;
+
+ /** An array of rendered log view instances. */
+ @state()
+ _logViews: LogView[] = [];
+
+ /** An object that stores the state of log views */
+ @state()
+ _stateStore: StateStore;
+
+ private _state: State;
+
+ constructor(state: StateStore) {
+ super();
+ this._stateStore = state;
+ this._state = this._stateStore.getState();
+ }
+
+ protected firstUpdated(): void {
+ if (this._state.logViewConfig.length == 0) {
+ this.addLogView();
+ return;
+ }
+
+ const viewState = this._state.logViewConfig;
+ const viewEls = [];
+ for (const i in viewState) {
+ const view = new LogView();
+ view.id = viewState[i].viewID;
+ view.searchText = viewState[i].search;
+ viewEls.push(view);
+ this._logViews = viewEls;
+ }
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.addEventListener('close-view', this.handleCloseView);
+
+ // If color scheme isn't set manually, retrieve it from localStorage
+ if (!this.colorScheme) {
+ const storedScheme = localStorage.getItem(
+ 'colorScheme',
+ ) as ColorScheme | null;
+ if (storedScheme) {
+ this.colorScheme = storedScheme;
+ }
+ }
+ }
+
+ updated(changedProperties: PropertyValues) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('colorScheme') && this.colorScheme) {
+ // Only store in localStorage if color scheme is 'dark' or 'light'
+ if (this.colorScheme === 'light' || this.colorScheme === 'dark') {
+ localStorage.setItem('colorScheme', this.colorScheme);
+ } else {
+ localStorage.removeItem('colorScheme');
+ }
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.removeEventListener('close-view', this.handleCloseView);
+ }
+
+ /** Creates a new log view in the `_logViews` arrray. */
+ private addLogView() {
+ const newView = new LogView();
+ const newViewState = this.addLogViewState(newView);
+ const viewStates: State = { logViewConfig: this._state.logViewConfig };
+ viewStates.logViewConfig.push(newViewState);
+ this._logViews = [...this._logViews, newView];
+ this._stateStore.setState(viewStates);
+ this._state = viewStates;
+ }
+
+ /** Creates a new log view state to store in the state object. */
+ private addLogViewState(view: LogView): LogViewConfig {
+ const fieldColumns = [];
+ const fields = view.getFields();
+
+ for (const i in fields) {
+ const col: TableColumn = {
+ isVisible: true,
+ fieldName: fields[i],
+ characterLength: 0,
+ manualWidth: null,
+ };
+ fieldColumns.push(col);
+ }
+
+ const obj = {
+ columnData: fieldColumns,
+ search: '',
+ viewID: view.id,
+ viewTitle: 'Log View',
+ };
+
+ return obj as LogViewConfig;
+ }
+
+ /**
+ * Removes a log view when its Close button is clicked.
+ *
+ * @param event The event object dispatched by the log view controls.
+ */
+ private handleCloseView(event: CloseViewEvent) {
+ const viewId = event.detail.viewId;
+ const index = this._logViews.findIndex((view) => view.id === viewId);
+ this._logViews = this._logViews.filter((view) => view.id !== viewId);
+
+ if (index > -1) {
+ this._state.logViewConfig.splice(index, 1);
+ this._stateStore.setState(this._state);
+ }
+ }
+
+ render() {
+ return html`
+ <div class="grid-container">
+ ${repeat(
+ this._logViews,
+ (view) => view.id,
+ (view) => html`
+ <log-view
+ id=${view.id}
+ .logs=${this.logs}
+ .isOneOfMany=${this._logViews.length > 1}
+ .stateStore=${this._stateStore}
+ @add-view="${this.addLogView}"
+ ></log-view>
+ `,
+ )}
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'log-viewer': LogViewer;
+ }
+}
diff --git a/pw_web/log-viewer/src/createLogViewer.ts b/pw_web/log-viewer/src/createLogViewer.ts
new file mode 100644
index 000000000..10ce61859
--- /dev/null
+++ b/pw_web/log-viewer/src/createLogViewer.ts
@@ -0,0 +1,63 @@
+// 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 { LogViewer as RootComponent } from './components/log-viewer';
+import { StateStore, LocalStorageState } from './shared/state';
+import { LogEntry } from '../src/shared/interfaces';
+import { LogSource } from '../src/log-source';
+
+import '@material/web/button/filled-button.js';
+import '@material/web/button/outlined-button.js';
+import '@material/web/checkbox/checkbox.js';
+import '@material/web/field/outlined-field.js';
+import '@material/web/textfield/outlined-text-field.js';
+import '@material/web/textfield/filled-text-field.js';
+import '@material/web/icon/icon.js';
+import '@material/web/iconbutton/icon-button.js';
+import '@material/web/menu/menu.js';
+import '@material/web/menu/menu-item.js';
+
+export function createLogViewer(
+ logSource: LogSource,
+ root: HTMLElement,
+ state: StateStore = new LocalStorageState(),
+) {
+ 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);
+ }
+
+ // Call requestUpdate at most once every 100 milliseconds.
+ lastUpdateTimeoutId = setTimeout(() => {
+ const updatedLogs = [...logs];
+ logViewer.logs = updatedLogs;
+ }, 100);
+ };
+
+ // Add the event listener to the LogSource instance
+ logSource.addEventListener('logEntry', logEntryListener);
+
+ // Method to destroy and unsubscribe
+ return () => {
+ logSource.removeEventListener('logEntry', 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
new file mode 100644
index 000000000..8914b1bc9
--- /dev/null
+++ b/pw_web/log-viewer/src/custom/json-log-source.ts
@@ -0,0 +1,131 @@
+// 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 { LogSource } from '../log-source';
+import { LogEntry, FieldData, Severity } from '../shared/interfaces';
+
+import log_data from './log_data.json';
+
+interface LevelToSeverity {
+ [level: number]: Severity;
+}
+
+export class JsonLogSource extends LogSource {
+ private intervalId: NodeJS.Timeout | null = null;
+ private logIndex: number = 0;
+ private previousLogTime: number = 0;
+
+ private logLevelToSeverity: LevelToSeverity = {
+ 10: Severity.DEBUG,
+ 20: Severity.INFO,
+ 21: Severity.INFO,
+ 30: Severity.WARNING,
+ 40: Severity.ERROR,
+ 50: Severity.CRITICAL,
+ 70: Severity.CRITICAL,
+ };
+
+ private nonAdditionalDataFields = [
+ '_hosttime',
+ 'levelname',
+ 'levelno',
+ 'args',
+ 'fields',
+ 'message',
+ 'time',
+ ];
+
+ constructor() {
+ super();
+ }
+
+ start(): void {
+ this.updateLogTime();
+
+ const getInterval = () => {
+ // Get the current log time
+ const next_log_time = Number(log_data[this.logIndex].time);
+ const wait_ms = 1000 * (next_log_time - this.previousLogTime);
+
+ this.updateLogTime();
+ return Math.round(wait_ms);
+ };
+
+ const readLogEntry = () => {
+ const logEntry = this.readLogEntryFromJson();
+ this.emitEvent('logEntry', logEntry);
+
+ const nextInterval = getInterval();
+ setTimeout(readLogEntry, nextInterval);
+ };
+
+ readLogEntry();
+ }
+
+ stop(): void {
+ if (this.intervalId) {
+ clearInterval(this.intervalId);
+ this.intervalId = null;
+ }
+ }
+
+ private updateLogTime(): void {
+ this.previousLogTime = Number(log_data[this.logIndex].time);
+ }
+
+ private updateLogIndex(): void {
+ this.logIndex += 1;
+ if (this.logIndex >= log_data.length) {
+ this.logIndex = 0;
+ }
+ }
+
+ readLogEntryFromJson(): LogEntry {
+ const data = log_data[this.logIndex];
+
+ const host_log_time = new Date(0); // Date set to epoch seconds 0
+ const host_log_epoch_seconds = Number(data.time);
+ host_log_time.setUTCSeconds(Math.trunc(host_log_epoch_seconds));
+ const host_log_epoch_milliseconds = Math.trunc(
+ 1000 * (host_log_epoch_seconds - Math.trunc(host_log_epoch_seconds)),
+ );
+ host_log_time.setUTCMilliseconds(host_log_epoch_milliseconds);
+
+ const fields: Array<FieldData> = [
+ { key: 'severity', value: this.logLevelToSeverity[data.levelno] },
+ { key: 'time', value: host_log_time },
+ ];
+
+ Object.keys(data.fields).forEach((columnName) => {
+ if (this.nonAdditionalDataFields.indexOf(columnName) === -1) {
+ // @ts-ignore
+ fields.push({ key: columnName, value: data.fields[columnName] });
+ }
+ });
+
+ fields.push({ key: 'message', value: data.message });
+ fields.push({ key: 'py_file', value: data.py_file || '' });
+ fields.push({ key: 'py_logger', value: data.py_logger || '' });
+
+ const logEntry: LogEntry = {
+ severity: this.logLevelToSeverity[data.levelno],
+ timestamp: new Date(),
+ fields: fields,
+ };
+
+ this.updateLogIndex();
+
+ return logEntry;
+ }
+}
diff --git a/pw_web/log-viewer/src/custom/log_data.json b/pw_web/log-viewer/src/custom/log_data.json
new file mode 100644
index 000000000..d006386d3
--- /dev/null
+++ b/pw_web/log-viewer/src/custom/log_data.json
@@ -0,0 +1,37 @@
+[
+ {
+ "message": "Sample log_data.json",
+ "levelno": 30,
+ "levelname": "WRN",
+ "time": "1692302986.4599075",
+ "time_string": "2023-08-17T13:09:46",
+ "fields": {
+ "module": "pigweedjs",
+ "timestamp": "1.0"
+ }
+ },
+ {
+ "message": "Log message 1",
+ "levelno": 20,
+ "levelname": "INF",
+ "time": "1692302986.4599075",
+ "time_string": "2023-08-17T13:09:46",
+ "fields": {
+ "module": "device",
+ "file": "sample_file.cc",
+ "timestamp": "1.0"
+ }
+ },
+ {
+ "message": "Log message 2",
+ "levelno": 20,
+ "levelname": "INF",
+ "time": "1692303000.1080465",
+ "time_string": "2023-08-17T13:10:00",
+ "fields": {
+ "module": "device",
+ "file": "sample_file.cc",
+ "timestamp": "14.0"
+ }
+ }
+]
diff --git a/pw_web/log-viewer/src/custom/mock-log-source.ts b/pw_web/log-viewer/src/custom/mock-log-source.ts
new file mode 100644
index 000000000..3f7afb96b
--- /dev/null
+++ b/pw_web/log-viewer/src/custom/mock-log-source.ts
@@ -0,0 +1,132 @@
+// 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 { LogSource } from '../log-source';
+import { LogEntry, Severity } from '../shared/interfaces';
+
+export class MockLogSource extends LogSource {
+ private intervalId: NodeJS.Timeout | null = null;
+
+ constructor() {
+ super();
+ }
+
+ start(): void {
+ const getRandomInterval = () => {
+ return Math.floor(Math.random() * (200 - 50 + 1) + 50);
+ };
+
+ const readLogEntry = () => {
+ const logEntry = this.readLogEntryFromHost();
+ this.emitEvent('logEntry', logEntry);
+
+ const nextInterval = getRandomInterval();
+ setTimeout(readLogEntry, nextInterval);
+ };
+
+ readLogEntry();
+ }
+
+ stop(): void {
+ if (this.intervalId) {
+ clearInterval(this.intervalId);
+ this.intervalId = null;
+ }
+ }
+
+ getSeverity(): Severity {
+ interface ValueWeightPair {
+ severity: Severity;
+ weight: number;
+ }
+
+ const valueWeightPairs: ValueWeightPair[] = [
+ { severity: Severity.INFO, weight: 7.45 },
+ { severity: Severity.DEBUG, weight: 0.25 },
+ { severity: Severity.WARNING, weight: 1.5 },
+ { severity: Severity.ERROR, weight: 0.5 },
+ { severity: Severity.CRITICAL, weight: 0.05 },
+ ];
+
+ const totalWeight = valueWeightPairs.reduce(
+ (acc, pair) => acc + pair.weight,
+ 0,
+ );
+ let randomValue = Severity.INFO;
+ let randomNum = Math.random() * totalWeight;
+
+ for (let i = 0; i < valueWeightPairs.length; i++) {
+ randomNum -= valueWeightPairs[i].weight;
+ if (randomNum <= 0) {
+ randomValue = valueWeightPairs[i].severity;
+ break;
+ }
+ }
+
+ return randomValue;
+ }
+
+ private formattedTS = () => {
+ const date = new Date();
+ const month = (date.getMonth() + 1).toString().padStart(2, '0');
+ const day = date.getDate().toString().padStart(2, '0');
+ const year = date.getFullYear().toString().padStart(4, '0');
+ const hour = date.getHours().toString().padStart(2, '0');
+ const minute = date.getMinutes().toString().padStart(2, '0');
+ const second = date.getSeconds().toString().padStart(2, '0');
+ const millisecond = date.getMilliseconds().toString().padStart(3, '0');
+
+ const formattedDate = `${month}-${day}-${year} ${hour}:${minute}:${second}.${millisecond}`;
+ return formattedDate;
+ };
+
+ readLogEntryFromHost(): LogEntry {
+ // Emulate reading log data from a host device
+ const sources = ['application', 'server', 'database', 'network'];
+ const messages = [
+ 'Request processed successfully!',
+ 'An unexpected error occurred while performing the operation.',
+ 'Connection timed out. Please check your network settings.',
+ 'Invalid input detected. Please provide valid data.',
+ 'Database connection lost. Attempting to reconnect.',
+ 'User authentication failed. Invalid credentials provided.',
+ 'System reboot initiated. Please wait for the system to come back online.',
+ 'File not found. (The requested file does not exist).',
+ 'Data corruption detected. Initiating recovery process.',
+ 'Network congestion detected. Traffic is high, please try again later.',
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam condimentum auctor justo, sit amet condimentum nibh facilisis non. Quisque in quam a urna dignissim cursus. Suspendisse egestas nisl sed massa dictum dictum. In tincidunt arcu nec odio eleifend, vel pharetra justo iaculis. Vivamus quis tellus ac velit vehicula consequat. Nam eu felis sed risus hendrerit faucibus ac id lacus. Vestibulum tincidunt tellus in ex feugiat interdum. Nulla sit amet luctus neque. Mauris et aliquet nunc, vel finibus massa. Curabitur laoreet eleifend nibh eget luctus. Fusce sodales augue nec purus faucibus, vel tristique enim vehicula. Aenean eu magna eros. Fusce accumsan dignissim dui auctor scelerisque. Proin ultricies nunc vel tincidunt facilisis.',
+ ];
+ const severity = this.getSeverity();
+ const timestamp: Date = new Date();
+
+ const formattedTimestamp: string = this.formattedTS();
+ const getRandomValue = (values: string[]) => {
+ const randomIndex = Math.floor(Math.random() * values.length);
+ return values[randomIndex];
+ };
+
+ const logEntry: LogEntry = {
+ severity: severity,
+ timestamp: timestamp,
+ fields: [
+ { key: 'severity', value: severity },
+ { key: 'timestamp', value: formattedTimestamp },
+ { key: 'source', value: getRandomValue(sources) },
+ { key: 'message', value: getRandomValue(messages) },
+ ],
+ };
+
+ return logEntry;
+ }
+}
diff --git a/pw_web/log-viewer/src/events/add-view.ts b/pw_web/log-viewer/src/events/add-view.ts
new file mode 100644
index 000000000..abe48877a
--- /dev/null
+++ b/pw_web/log-viewer/src/events/add-view.ts
@@ -0,0 +1,23 @@
+// 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.
+
+interface AddViewEvent extends CustomEvent {}
+
+declare global {
+ interface GlobalEventHandlersEventMap {
+ 'add-view': AddViewEvent;
+ }
+}
+
+export default AddViewEvent;
diff --git a/pw_web/log-viewer/src/events/clear-logs.ts b/pw_web/log-viewer/src/events/clear-logs.ts
new file mode 100644
index 000000000..8ae69a258
--- /dev/null
+++ b/pw_web/log-viewer/src/events/clear-logs.ts
@@ -0,0 +1,27 @@
+// 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.
+
+interface ClearLogsEvent extends CustomEvent {
+ detail: {
+ timestamp: Date;
+ };
+}
+
+declare global {
+ interface GlobalEventHandlersEventMap {
+ 'clear-logs': ClearLogsEvent;
+ }
+}
+
+export default ClearLogsEvent;
diff --git a/pw_web/log-viewer/src/events/close-view.ts b/pw_web/log-viewer/src/events/close-view.ts
new file mode 100644
index 000000000..307752359
--- /dev/null
+++ b/pw_web/log-viewer/src/events/close-view.ts
@@ -0,0 +1,27 @@
+// 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.
+
+interface CloseViewEvent extends CustomEvent {
+ detail: {
+ viewId: string;
+ };
+}
+
+declare global {
+ interface GlobalEventHandlersEventMap {
+ 'close-view': CloseViewEvent;
+ }
+}
+
+export default CloseViewEvent;
diff --git a/pw_web/log-viewer/src/events/column-toggle.ts b/pw_web/log-viewer/src/events/column-toggle.ts
new file mode 100644
index 000000000..8bacaee42
--- /dev/null
+++ b/pw_web/log-viewer/src/events/column-toggle.ts
@@ -0,0 +1,28 @@
+// 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.
+
+interface ColumnToggleEvent extends CustomEvent {
+ detail: {
+ field: string;
+ isChecked: boolean;
+ };
+}
+
+declare global {
+ interface GlobalEventHandlersEventMap {
+ 'column-toggle': ColumnToggleEvent;
+ }
+}
+
+export default ColumnToggleEvent;
diff --git a/pw_web/log-viewer/src/events/download-logs.ts b/pw_web/log-viewer/src/events/download-logs.ts
new file mode 100644
index 000000000..fc9b54aa6
--- /dev/null
+++ b/pw_web/log-viewer/src/events/download-logs.ts
@@ -0,0 +1,28 @@
+// 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.
+
+interface DownloadLogsEvent extends CustomEvent {
+ detail: {
+ format: string;
+ viewTitle: string;
+ };
+}
+
+declare global {
+ interface GlobalEventHandlersEventMap {
+ 'download-logs': DownloadLogsEvent;
+ }
+}
+
+export default DownloadLogsEvent;
diff --git a/pw_web/log-viewer/src/events/input-change.ts b/pw_web/log-viewer/src/events/input-change.ts
new file mode 100644
index 000000000..29b69f91a
--- /dev/null
+++ b/pw_web/log-viewer/src/events/input-change.ts
@@ -0,0 +1,27 @@
+// 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.
+
+interface InputChangeEvent extends CustomEvent {
+ detail: {
+ inputValue: string;
+ };
+}
+
+declare global {
+ interface GlobalEventHandlersEventMap {
+ 'input-change': InputChangeEvent;
+ }
+}
+
+export default InputChangeEvent;
diff --git a/pw_web/log-viewer/src/events/wrap-toggle.ts b/pw_web/log-viewer/src/events/wrap-toggle.ts
new file mode 100644
index 000000000..de4f1022a
--- /dev/null
+++ b/pw_web/log-viewer/src/events/wrap-toggle.ts
@@ -0,0 +1,27 @@
+// 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.
+
+interface WrapToggleEvent extends CustomEvent {
+ detail: {
+ isChecked: boolean;
+ };
+}
+
+declare global {
+ interface GlobalEventHandlersEventMap {
+ 'wrap-toggle': WrapToggleEvent;
+ }
+}
+
+export default WrapToggleEvent;
diff --git a/pw_web/log-viewer/src/index.css b/pw_web/log-viewer/src/index.css
new file mode 100644
index 000000000..04a79a2c2
--- /dev/null
+++ b/pw_web/log-viewer/src/index.css
@@ -0,0 +1,71 @@
+/*
+ * 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 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');
+
+:root {
+ background-color: #fff;
+ font-family: "Roboto Flex", Arial, sans-serif;
+ font-synthesis: none;
+ font-weight: 400;
+ line-height: 1.5;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ background-color: #131314;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+button {
+ font-family: "Roboto Flex";
+}
+
+main {
+ height: 100vh;
+ padding: 16px;
+ width: 100vw;
+}
+
+@media (min-width: 840px) {
+ main {
+ padding: 24px;
+ }
+}
+
+a {
+ color: var(--md-sys-color-primary);
+ font-weight: 500;
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: var(--md-sys-color-secondary);
+}
+
+body {
+ display: grid;
+ place-content: start;
+ margin: 0;
+} \ No newline at end of file
diff --git a/pw_web/log-viewer/src/index.ts b/pw_web/log-viewer/src/index.ts
new file mode 100644
index 000000000..b50399561
--- /dev/null
+++ b/pw_web/log-viewer/src/index.ts
@@ -0,0 +1,28 @@
+// 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 { JsonLogSource } from './custom/json-log-source';
+import { createLogViewer } from './createLogViewer';
+
+const logSource = new JsonLogSource();
+const containerEl = document.querySelector(
+ '#log-viewer-container',
+) as HTMLElement;
+
+if (containerEl) {
+ createLogViewer(logSource, containerEl);
+}
+
+// Start reading log data
+logSource.start();
diff --git a/pw_web/log-viewer/src/log-source.ts b/pw_web/log-viewer/src/log-source.ts
new file mode 100644
index 000000000..d000a46d0
--- /dev/null
+++ b/pw_web/log-viewer/src/log-source.ts
@@ -0,0 +1,52 @@
+// 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';
+
+export abstract class LogSource {
+ private eventListeners: {
+ eventType: string;
+ listener: (data: LogEntry) => void;
+ }[];
+
+ constructor() {
+ this.eventListeners = [];
+ }
+
+ addEventListener(
+ eventType: string,
+ listener: (data: LogEntry) => void,
+ ): void {
+ this.eventListeners.push({ eventType, listener });
+ }
+
+ removeEventListener(
+ eventType: string,
+ listener: (data: LogEntry) => void,
+ ): void {
+ this.eventListeners = this.eventListeners.filter(
+ (eventListener) =>
+ eventListener.eventType !== eventType ||
+ eventListener.listener !== listener,
+ );
+ }
+
+ emitEvent(eventType: string, data: LogEntry): void {
+ this.eventListeners.forEach((eventListener) => {
+ if (eventListener.eventType === eventType) {
+ eventListener.listener(data);
+ }
+ });
+ }
+}
diff --git a/pw_web/log-viewer/src/shared/interfaces.ts b/pw_web/log-viewer/src/shared/interfaces.ts
new file mode 100644
index 000000000..2b1ac5946
--- /dev/null
+++ b/pw_web/log-viewer/src/shared/interfaces.ts
@@ -0,0 +1,50 @@
+// 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.
+
+export interface FieldData {
+ key: string;
+ value: string | boolean | number | object;
+}
+
+export interface TableColumn {
+ fieldName: string;
+ characterLength: number;
+ manualWidth: number | null;
+ isVisible: boolean;
+}
+
+export interface LogEntry {
+ severity?: Severity;
+ timestamp: Date;
+ fields: FieldData[];
+}
+
+export interface LogViewConfig {
+ columnData: TableColumn[];
+ search: string;
+ viewID: string;
+ viewTitle: string;
+}
+
+export enum Severity {
+ DEBUG = 'DEBUG',
+ INFO = 'INFO',
+ WARNING = 'WARNING',
+ ERROR = 'ERROR',
+ CRITICAL = 'CRITICAL',
+}
+
+export interface State {
+ logViewConfig: LogViewConfig[];
+}
diff --git a/pw_web/log-viewer/src/shared/state.ts b/pw_web/log-viewer/src/shared/state.ts
new file mode 100644
index 000000000..87a379f87
--- /dev/null
+++ b/pw_web/log-viewer/src/shared/state.ts
@@ -0,0 +1,42 @@
+// 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 { State } from './interfaces';
+
+/**
+ * Abstract Class for StateStore.
+ */
+export abstract class StateStore {
+ abstract getState(): State;
+ abstract setState(state: State): void;
+}
+
+/**
+ * LocalStorage version of StateStore
+ */
+export class LocalStorageState extends StateStore {
+ getState(): State {
+ try {
+ const state = localStorage.getItem('logState') as string;
+ return state == null ? { logViewConfig: [] } : JSON.parse(state);
+ } catch (e) {
+ console.error(e);
+ return { logViewConfig: [] };
+ }
+ }
+
+ setState(state: State): void {
+ localStorage.setItem('logState', JSON.stringify(state));
+ }
+}
diff --git a/pw_web/log-viewer/src/themes/dark.ts b/pw_web/log-viewer/src/themes/dark.ts
new file mode 100644
index 000000000..a9744f520
--- /dev/null
+++ b/pw_web/log-viewer/src/themes/dark.ts
@@ -0,0 +1,211 @@
+// 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 { css } from 'lit';
+
+export const themeDark = css`
+ /* Automatic theme styles */
+ @media (prefers-color-scheme: dark) {
+ :host {
+ color-scheme: dark;
+
+ /* Material Design tokens */
+ --md-sys-color-primary: #a8c7fa;
+ --md-sys-color-primary-60: #4c8df6;
+ --md-sys-color-primary-container: #0842a0;
+ --md-sys-color-on-primary: #062e6f;
+ --md-sys-color-on-primary-container: #d3e3fd;
+ --md-sys-color-inverse-primary: #0b57d0;
+ --md-sys-color-secondary: #7fcfff;
+ --md-sys-color-secondary-container: #004a77;
+ --md-sys-color-on-secondary: #003355;
+ --md-sys-color-on-secondary-container: #c2e7ff;
+ --md-sys-color-tertiary: #6dd58c;
+ --md-sys-color-tertiary-container: #0f5223;
+ --md-sys-color-on-tertiary: #0a3818;
+ --md-sys-color-on-tertiary-container: #c4eed0;
+ --md-sys-color-surface: #131314;
+ --md-sys-color-surface-dim: #131314;
+ --md-sys-color-surface-bright: #37393b;
+ --md-sys-color-surface-container-lowest: #0e0e0e;
+ --md-sys-color-surface-container-low: #1b1b1b;
+ --md-sys-color-surface-container: #1e1f20;
+ --md-sys-color-surface-container-high: #282a2c;
+ --md-sys-color-surface-container-highest: #333537;
+ --md-sys-color-on-surface: #e3e3e3;
+ --md-sys-color-on-surface-variant: #c4c7c5;
+ --md-sys-color-inverse-surface: #e3e3e3;
+ --md-sys-color-inverse-on-surface: #303030;
+ --md-sys-color-outline: #8e918f;
+ --md-sys-color-outline-variant: #444746;
+ --md-sys-color-shadow: #000000;
+ --md-sys-color-scrim: #000000;
+ --md-sys-color-inverse-surface-rgb: 230, 225, 229;
+
+ /* General */
+ --sys-log-viewer-color-primary: var(--md-sys-color-primary);
+ --sys-log-viewer-color-on-primary: var(--md-sys-color-on-primary);
+
+ /* Log Viewer */
+ --sys-log-viewer-color-bg: var(--md-sys-color-surface);
+
+ /* Log View */
+ --sys-log-viewer-color-view-outline: var(--md-sys-color-outline-variant);
+
+ /* Log View Controls */
+ --sys-log-viewer-color-controls-bg: var(
+ --md-sys-color-surface-container-high
+ );
+ --sys-log-viewer-color-controls-text: var(
+ --md-sys-color-on-surface-variant
+ );
+ --sys-log-viewer-color-controls-input-outline: transparent;
+ --sys-log-viewer-color-controls-input-bg: var(--md-sys-color-surface);
+ --sys-log-viewer-color-controls-button-enabled: var(
+ --md-sys-color-primary-container
+ );
+
+ /* Log List */
+ --sys-log-viewer-color-table-header-bg: var(
+ --md-sys-color-surface-container
+ );
+ --sys-log-viewer-color-table-header-text: var(--md-sys-color-on-surface);
+ --sys-log-viewer-color-table-bg: var(
+ --md-sys-color-surface-container-lowest
+ );
+ --sys-log-viewer-color-table-text: var(--md-sys-color-on-surface);
+ --sys-log-viewer-color-table-cell-outline: var(
+ --md-sys-color-outline-variant
+ );
+ --sys-log-viewer-color-overflow-indicator: var(
+ --md-sys-color-surface-container-lowest
+ );
+ --sys-log-viewer-color-table-mark: var(--md-sys-color-primary-container);
+ --sys-log-viewer-color-table-mark-text: var(
+ --md-sys-color-on-primary-container
+ );
+ --sys-log-viewer-color-table-mark-outline: var(
+ --md-sys-color-outline-variant
+ );
+ --sys-log-viewer-color-table-row-highlight: var(
+ --md-sys-color-inverse-surface-rgb
+ );
+
+ /* Severity */
+ --sys-log-viewer-color-error-bright: #e46962;
+ --sys-log-viewer-color-surface-error: #601410;
+ --sys-log-viewer-color-on-surface-error: #f9dedc;
+ --sys-log-viewer-color-orange-bright: #ee9836;
+ --sys-log-viewer-color-surface-yellow: #402d00;
+ --sys-log-viewer-color-on-surface-yellow: #ffdfa0;
+ --sys-log-viewer-color-debug: var(--md-sys-color-primary-60);
+ }
+ }
+
+ /* Manual theme styles */
+ :host([colorscheme='dark']) {
+ color-scheme: dark;
+
+ /* Material Design tokens */
+ --md-sys-color-primary: #a8c7fa;
+ --md-sys-color-primary-60: #4c8df6;
+ --md-sys-color-primary-container: #0842a0;
+ --md-sys-color-on-primary: #062e6f;
+ --md-sys-color-on-primary-container: #d3e3fd;
+ --md-sys-color-inverse-primary: #0b57d0;
+ --md-sys-color-secondary: #7fcfff;
+ --md-sys-color-secondary-container: #004a77;
+ --md-sys-color-on-secondary: #003355;
+ --md-sys-color-on-secondary-container: #c2e7ff;
+ --md-sys-color-tertiary: #6dd58c;
+ --md-sys-color-tertiary-container: #0f5223;
+ --md-sys-color-on-tertiary: #0a3818;
+ --md-sys-color-on-tertiary-container: #c4eed0;
+ --md-sys-color-surface: #131314;
+ --md-sys-color-surface-dim: #131314;
+ --md-sys-color-surface-bright: #37393b;
+ --md-sys-color-surface-container-lowest: #0e0e0e;
+ --md-sys-color-surface-container-low: #1b1b1b;
+ --md-sys-color-surface-container: #1e1f20;
+ --md-sys-color-surface-container-high: #282a2c;
+ --md-sys-color-surface-container-highest: #333537;
+ --md-sys-color-on-surface: #e3e3e3;
+ --md-sys-color-on-surface-variant: #c4c7c5;
+ --md-sys-color-inverse-surface: #e3e3e3;
+ --md-sys-color-inverse-on-surface: #303030;
+ --md-sys-color-outline: #8e918f;
+ --md-sys-color-outline-variant: #444746;
+ --md-sys-color-shadow: #000000;
+ --md-sys-color-scrim: #000000;
+ --md-sys-color-inverse-surface-rgb: 230, 225, 229;
+
+ /* General */
+ --sys-log-viewer-color-primary: var(--md-sys-color-primary);
+ --sys-log-viewer-color-on-primary: var(--md-sys-color-on-primary);
+
+ /* Log Viewer */
+ --sys-log-viewer-color-bg: var(--md-sys-color-surface);
+
+ /* Log View */
+ --sys-log-viewer-color-view-outline: var(--md-sys-color-outline-variant);
+
+ /* Log View Controls */
+ --sys-log-viewer-color-controls-bg: var(
+ --md-sys-color-surface-container-high
+ );
+ --sys-log-viewer-color-controls-text: var(
+ --md-sys-color-on-surface-variant
+ );
+ --sys-log-viewer-color-controls-input-outline: transparent;
+ --sys-log-viewer-color-controls-input-bg: var(--md-sys-color-surface);
+ --sys-log-viewer-color-controls-button-enabled: var(
+ --md-sys-color-primary-container
+ );
+
+ /* Log List */
+ --sys-log-viewer-color-table-header-bg: var(
+ --md-sys-color-surface-container
+ );
+ --sys-log-viewer-color-table-header-text: var(--md-sys-color-on-surface);
+ --sys-log-viewer-color-table-bg: var(
+ --md-sys-color-surface-container-lowest
+ );
+ --sys-log-viewer-color-table-text: var(--md-sys-color-on-surface);
+ --sys-log-viewer-color-table-cell-outline: var(
+ --md-sys-color-outline-variant
+ );
+ --sys-log-viewer-color-overflow-indicator: var(
+ --md-sys-color-surface-container-lowest
+ );
+ --sys-log-viewer-color-table-mark: var(--md-sys-color-primary-container);
+ --sys-log-viewer-color-table-mark-text: var(
+ --md-sys-color-on-primary-container
+ );
+ --sys-log-viewer-color-table-mark-outline: var(
+ --md-sys-color-outline-variant
+ );
+ --sys-log-viewer-color-table-row-highlight: var(
+ --md-sys-color-inverse-surface-rgb
+ );
+
+ /* Severity */
+ --sys-log-viewer-color-error-bright: #e46962;
+ --sys-log-viewer-color-surface-error: #601410;
+ --sys-log-viewer-color-on-surface-error: #f9dedc;
+ --sys-log-viewer-color-orange-bright: #ee9836;
+ --sys-log-viewer-color-surface-yellow: #402d00;
+ --sys-log-viewer-color-on-surface-yellow: #ffdfa0;
+ --sys-log-viewer-color-debug: var(--md-sys-color-primary-60);
+ }
+`;
diff --git a/pw_web/log-viewer/src/themes/light.ts b/pw_web/log-viewer/src/themes/light.ts
new file mode 100644
index 000000000..e179cfb95
--- /dev/null
+++ b/pw_web/log-viewer/src/themes/light.ts
@@ -0,0 +1,213 @@
+// 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 { css } from 'lit';
+
+export const themeLight = css`
+ /* Automatic theme styles */
+ @media (prefers-color-scheme: light) {
+ :host {
+ color-scheme: light;
+
+ /* Material Design tokens */
+ --md-sys-color-primary: #0b57d0;
+ --md-sys-color-primary-70: #7cacf8;
+ --md-sys-color-primary-90: #d3e3fd;
+ --md-sys-color-primary-95: #ecf3fe;
+ --md-sys-color-primary-99: #fafbff;
+ --md-sys-color-primary-container: #d3e3fd;
+ --md-sys-color-on-primary: #ffffff;
+ --md-sys-color-on-primary-container: #041e49;
+ --md-sys-color-inverse-primary: #a8c7fa;
+ --md-sys-color-secondary: #00639b;
+ --md-sys-color-secondary-container: #c2e7ff;
+ --md-sys-color-on-secondary: #ffffff;
+ --md-sys-color-on-secondary-container: #001d35;
+ --md-sys-color-tertiary: #146c2e;
+ --md-sys-color-tertiary-container: #c4eed0;
+ --md-sys-color-on-tertiary: #ffffff;
+ --md-sys-color-on-tertiary-container: #072711;
+ --md-sys-color-surface: #ffffff;
+ --md-sys-color-surface-dim: #d3dbe5;
+ --md-sys-color-surface-bright: #ffffff;
+ --md-sys-color-surface-container-lowest: #ffffff;
+ --md-sys-color-surface-container-low: #f8fafd;
+ --md-sys-color-surface-container: #f0f4f9;
+ --md-sys-color-surface-container-high: #e9eef6;
+ --md-sys-color-surface-container-highest: #dde3ea;
+ --md-sys-color-on-surface: #1f1f1f;
+ --md-sys-color-on-surface-variant: #444746;
+ --md-sys-color-inverse-surface: #303030;
+ --md-sys-color-inverse-on-surface: #f2f2f2;
+ --md-sys-color-outline: #747775;
+ --md-sys-color-outline-variant: #c4c7c5;
+ --md-sys-color-shadow: #000000;
+ --md-sys-color-scrim: #000000;
+ --md-sys-color-inverse-surface-rgb: 49, 48, 51;
+
+ /* General */
+ --sys-log-viewer-color-primary: var(--md-sys-color-primary);
+ --sys-log-viewer-color-on-primary: var(--md-sys-color-on-primary);
+
+ /* Log Viewer */
+ --sys-log-viewer-color-bg: var(--md-sys-color-surface);
+
+ /* Log View */
+ --sys-log-viewer-color-view-outline: var(--md-sys-color-outline);
+
+ /* Log View Controls */
+ --sys-log-viewer-color-controls-bg: var(--md-sys-color-primary-90);
+ --sys-log-viewer-color-controls-text: var(
+ --md-sys-color-on-primary-container
+ );
+ --sys-log-viewer-color-controls-input-outline: transparent;
+ --sys-log-viewer-color-controls-input-bg: var(
+ --md-sys-color-surface-container-lowest
+ );
+ --sys-log-viewer-color-controls-button-enabled: var(
+ --md-sys-color-primary-70
+ );
+
+ /* Log List */
+ --sys-log-viewer-color-table-header-bg: var(--md-sys-color-primary-95);
+ --sys-log-viewer-color-table-header-text: var(--md-sys-color-on-surface);
+ --sys-log-viewer-color-table-bg: var(
+ --md-sys-color-surface-container-lowest
+ );
+ --sys-log-viewer-color-table-text: var(--md-sys-color-on-surface);
+ --sys-log-viewer-color-table-cell-outline: var(
+ --md-sys-color-outline-variant
+ );
+ --sys-log-viewer-color-overflow-indicator: var(
+ --md-sys-color-surface-container
+ );
+ --sys-log-viewer-color-table-mark: var(--md-sys-color-primary-container);
+ --sys-log-viewer-color-table-mark-text: var(
+ --md-sys-color-on-primary-container
+ );
+ --sys-log-viewer-color-table-mark-outline: var(
+ --md-sys-color-outline-variant
+ );
+ --sys-log-viewer-color-table-row-highlight: var(
+ --md-sys-color-inverse-surface-rgb
+ );
+
+ /* Severity */
+ --sys-log-viewer-color-error-bright: #dc362e;
+ --sys-log-viewer-color-surface-error: #fcefee;
+ --sys-log-viewer-color-on-surface-error: #8c1d18;
+ --sys-log-viewer-color-orange-bright: #f49f2a;
+ --sys-log-viewer-color-surface-yellow: #fef9eb;
+ --sys-log-viewer-color-on-surface-yellow: #783616;
+ --sys-log-viewer-color-debug: var(--md-sys-color-primary);
+ }
+ }
+
+ /* Manual theme styles */
+ :host([colorscheme='light']) {
+ color-scheme: light;
+
+ /* Material Design tokens */
+ --md-sys-color-primary: #0b57d0;
+ --md-sys-color-primary-70: #7cacf8;
+ --md-sys-color-primary-90: #d3e3fd;
+ --md-sys-color-primary-95: #ecf3fe;
+ --md-sys-color-primary-99: #fafbff;
+ --md-sys-color-primary-container: #d3e3fd;
+ --md-sys-color-on-primary: #ffffff;
+ --md-sys-color-on-primary-container: #041e49;
+ --md-sys-color-inverse-primary: #a8c7fa;
+ --md-sys-color-secondary: #00639b;
+ --md-sys-color-secondary-container: #c2e7ff;
+ --md-sys-color-on-secondary: #ffffff;
+ --md-sys-color-on-secondary-container: #001d35;
+ --md-sys-color-tertiary: #146c2e;
+ --md-sys-color-tertiary-container: #c4eed0;
+ --md-sys-color-on-tertiary: #ffffff;
+ --md-sys-color-on-tertiary-container: #072711;
+ --md-sys-color-surface: #ffffff;
+ --md-sys-color-surface-dim: #d3dbe5;
+ --md-sys-color-surface-bright: #ffffff;
+ --md-sys-color-surface-container-lowest: #ffffff;
+ --md-sys-color-surface-container-low: #f8fafd;
+ --md-sys-color-surface-container: #f0f4f9;
+ --md-sys-color-surface-container-high: #e9eef6;
+ --md-sys-color-surface-container-highest: #dde3ea;
+ --md-sys-color-on-surface: #1f1f1f;
+ --md-sys-color-on-surface-variant: #444746;
+ --md-sys-color-inverse-surface: #303030;
+ --md-sys-color-inverse-on-surface: #f2f2f2;
+ --md-sys-color-outline: #747775;
+ --md-sys-color-outline-variant: #c4c7c5;
+ --md-sys-color-shadow: #000000;
+ --md-sys-color-scrim: #000000;
+ --md-sys-color-inverse-surface-rgb: 49, 48, 51;
+
+ /* General */
+ --sys-log-viewer-color-primary: var(--md-sys-color-primary);
+ --sys-log-viewer-color-on-primary: var(--md-sys-color-on-primary);
+
+ /* Log Viewer */
+ --sys-log-viewer-color-bg: var(--md-sys-color-surface);
+
+ /* Log View */
+ --sys-log-viewer-color-view-outline: var(--md-sys-color-outline);
+
+ /* Log View Controls */
+ --sys-log-viewer-color-controls-bg: var(--md-sys-color-primary-90);
+ --sys-log-viewer-color-controls-text: var(
+ --md-sys-color-on-primary-container
+ );
+ --sys-log-viewer-color-controls-input-outline: transparent;
+ --sys-log-viewer-color-controls-input-bg: var(
+ --md-sys-color-surface-container-lowest
+ );
+ --sys-log-viewer-color-controls-button-enabled: var(
+ --md-sys-color-primary-70
+ );
+
+ /* Log List */
+ --sys-log-viewer-color-table-header-bg: var(--md-sys-color-primary-95);
+ --sys-log-viewer-color-table-header-text: var(--md-sys-color-on-surface);
+ --sys-log-viewer-color-table-bg: var(
+ --md-sys-color-surface-container-lowest
+ );
+ --sys-log-viewer-color-table-text: var(--md-sys-color-on-surface);
+ --sys-log-viewer-color-table-cell-outline: var(
+ --md-sys-color-outline-variant
+ );
+ --sys-log-viewer-color-overflow-indicator: var(
+ --md-sys-color-surface-container
+ );
+ --sys-log-viewer-color-table-mark: var(--md-sys-color-primary-container);
+ --sys-log-viewer-color-table-mark-text: var(
+ --md-sys-color-on-primary-container
+ );
+ --sys-log-viewer-color-table-mark-outline: var(
+ --md-sys-color-outline-variant
+ );
+ --sys-log-viewer-color-table-row-highlight: var(
+ --md-sys-color-inverse-surface-rgb
+ );
+
+ /* Severity */
+ --sys-log-viewer-color-error-bright: #dc362e;
+ --sys-log-viewer-color-surface-error: #fcefee;
+ --sys-log-viewer-color-on-surface-error: #8c1d18;
+ --sys-log-viewer-color-orange-bright: #f49f2a;
+ --sys-log-viewer-color-surface-yellow: #fef9eb;
+ --sys-log-viewer-color-on-surface-yellow: #783616;
+ --sys-log-viewer-color-debug: var(--md-sys-color-primary);
+ }
+`;
diff --git a/pw_web/log-viewer/src/utils/log-filter/log-filter.models.ts b/pw_web/log-viewer/src/utils/log-filter/log-filter.models.ts
new file mode 100644
index 000000000..2fabd7b51
--- /dev/null
+++ b/pw_web/log-viewer/src/utils/log-filter/log-filter.models.ts
@@ -0,0 +1,61 @@
+// 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.
+
+export enum ConditionType {
+ StringSearch,
+ ColumnSearch,
+ ExactPhraseSearch,
+ AndExpression,
+ OrExpression,
+ NotExpression,
+}
+
+export type StringSearchCondition = {
+ type: ConditionType.StringSearch;
+ searchString: string;
+};
+
+export type ColumnSearchCondition = {
+ type: ConditionType.ColumnSearch;
+ column: string;
+ value?: string;
+};
+
+export type ExactPhraseSearchCondition = {
+ type: ConditionType.ExactPhraseSearch;
+ exactPhrase: string;
+};
+
+export type AndExpressionCondition = {
+ type: ConditionType.AndExpression;
+ expressions: FilterCondition[];
+};
+
+export type OrExpressionCondition = {
+ type: ConditionType.OrExpression;
+ expressions: FilterCondition[];
+};
+
+export type NotExpressionCondition = {
+ type: ConditionType.NotExpression;
+ expression: FilterCondition;
+};
+
+export type FilterCondition =
+ | ColumnSearchCondition
+ | StringSearchCondition
+ | ExactPhraseSearchCondition
+ | AndExpressionCondition
+ | OrExpressionCondition
+ | NotExpressionCondition;
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
new file mode 100644
index 000000000..344bba790
--- /dev/null
+++ b/pw_web/log-viewer/src/utils/log-filter/log-filter.ts
@@ -0,0 +1,254 @@
+// 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 { FilterCondition, ConditionType } from './log-filter.models';
+
+export class LogFilter {
+ /**
+ * Generates a structured representation of filter conditions which can be
+ * used to filter log entries.
+ *
+ * @param {string} searchQuery - The search query string provided.
+ * @returns {function[]} An array of filter functions, each representing a
+ * set of conditions grouped by logical operators, for filtering log
+ * entries.
+ */
+ static parseSearchQuery(searchQuery: string): FilterCondition[] {
+ const filters: FilterCondition[] = [];
+ const orGroups = searchQuery.split(/\s*\|\s*/);
+
+ for (let i = 0; i < orGroups.length; i++) {
+ let orGroup = orGroups[i];
+
+ if (orGroup.includes('(') && !orGroup.includes(')')) {
+ let j = i + 1;
+ while (j < orGroups.length && !orGroups[j].includes(')')) {
+ orGroup += ` | ${orGroups[j]}`;
+ j++;
+ }
+
+ if (j < orGroups.length) {
+ orGroup += ` | ${orGroups[j]}`;
+ i = j;
+ }
+ }
+
+ const andConditions = orGroup.match(
+ /\([^()]*\)|"[^"]+"|[^\s:]+:[^\s]+|[^\s]+/g,
+ );
+
+ const andFilters: FilterCondition[] = [];
+
+ if (andConditions) {
+ for (const condition of andConditions) {
+ if (condition.startsWith('(') && condition.endsWith(')')) {
+ const nestedConditions = condition.slice(1, -1).trim();
+ andFilters.push(...this.parseSearchQuery(nestedConditions));
+ } else if (condition.startsWith('"') && condition.endsWith('"')) {
+ const exactPhrase = condition.slice(1, -1).trim();
+ andFilters.push({
+ type: ConditionType.ExactPhraseSearch,
+ exactPhrase,
+ });
+ } else if (condition.startsWith('!')) {
+ const column = condition.slice(1, condition.indexOf(':'));
+ const value = condition.slice(condition.indexOf(':') + 1);
+ andFilters.push({
+ type: ConditionType.NotExpression,
+ expression: {
+ type: ConditionType.ColumnSearch,
+ column,
+ value,
+ },
+ });
+ } else if (condition.endsWith(':')) {
+ const column = condition.slice(0, condition.indexOf(':'));
+ andFilters.push({
+ type: ConditionType.ColumnSearch,
+ column,
+ });
+ } else if (condition.includes(':')) {
+ const column = condition.slice(0, condition.indexOf(':'));
+ const value = condition.slice(condition.indexOf(':') + 1);
+ andFilters.push({
+ type: ConditionType.ColumnSearch,
+ column,
+ value,
+ });
+ } else {
+ andFilters.push({
+ type: ConditionType.StringSearch,
+ searchString: condition,
+ });
+ }
+ }
+ }
+
+ if (andFilters.length > 0) {
+ if (andFilters.length === 1) {
+ filters.push(andFilters[0]);
+ } else {
+ filters.push({
+ type: ConditionType.AndExpression,
+ expressions: andFilters,
+ });
+ }
+ }
+ }
+
+ if (filters.length === 0) {
+ filters.push({
+ type: ConditionType.StringSearch,
+ searchString: '',
+ });
+ }
+
+ if (filters.length > 1) {
+ return [
+ {
+ type: ConditionType.OrExpression,
+ expressions: filters,
+ },
+ ];
+ }
+
+ return filters;
+ }
+
+ /**
+ * Takes a condition node, which represents a specific filter condition, and
+ * recursively generates a filter function that can be applied to log
+ * entries.
+ *
+ * @param {FilterCondition} condition - A filter condition to convert to a
+ * function.
+ * @returns {function} A function for filtering log entries based on the
+ * input condition and its logical operators.
+ */
+ static createFilterFunction(
+ condition: FilterCondition,
+ ): (logEntry: LogEntry) => boolean {
+ switch (condition.type) {
+ case ConditionType.StringSearch:
+ return (logEntry) =>
+ this.checkStringInColumns(logEntry, condition.searchString);
+ case ConditionType.ExactPhraseSearch:
+ return (logEntry) =>
+ this.checkExactPhraseInColumns(logEntry, condition.exactPhrase);
+ case ConditionType.ColumnSearch:
+ return (logEntry) =>
+ this.checkColumn(logEntry, condition.column, condition.value);
+ case ConditionType.NotExpression: {
+ const innerFilter = this.createFilterFunction(condition.expression);
+ return (logEntry) => !innerFilter(logEntry);
+ }
+ case ConditionType.AndExpression: {
+ const andFilters = condition.expressions.map((expr) =>
+ this.createFilterFunction(expr),
+ );
+ return (logEntry) => andFilters.every((filter) => filter(logEntry));
+ }
+ case ConditionType.OrExpression: {
+ const orFilters = condition.expressions.map((expr) =>
+ this.createFilterFunction(expr),
+ );
+ return (logEntry) => orFilters.some((filter) => filter(logEntry));
+ }
+ default:
+ // Return a filter that matches all entries
+ return () => true;
+ }
+ }
+
+ /**
+ * Checks if the column exists in a log entry and then performs a value
+ * search on the column's value.
+ *
+ * @param {LogEntry} logEntry - The log entry to be searched.
+ * @param {string} column - The name of the column (log entry field) to be
+ * checked for filtering.
+ * @param {string} value - An optional string that represents the value used
+ * for filtering.
+ * @returns {boolean} True if the specified column exists in the log entry,
+ * or if a value is provided, returns true if the value matches a
+ * substring of the column's value (case-insensitive).
+ */
+ private static checkColumn(
+ logEntry: LogEntry,
+ column: string,
+ value?: string,
+ ): boolean {
+ const fieldData = logEntry.fields.find((field) => field.key === column);
+ if (!fieldData) return false;
+
+ if (value === undefined) {
+ return true;
+ }
+
+ const searchRegex = new RegExp(value, 'i');
+ return searchRegex.test(fieldData.value.toString());
+ }
+
+ /**
+ * Checks if the provided search string exists in any of the log entry
+ * columns (excluding `severity`).
+ *
+ * @param {LogEntry} logEntry - The log entry to be searched.
+ * @param {string} searchString - The search string to be matched against
+ * the log entry fields.
+ * @returns {boolean} True if the search string is found in any of the log
+ * entry fields, otherwise false.
+ */
+ private static checkStringInColumns(
+ logEntry: LogEntry,
+ searchString: string,
+ ): boolean {
+ const escapedSearchString = this.escapeRegEx(searchString);
+ const columnsToSearch = logEntry.fields.filter(
+ (field) => field.key !== 'severity',
+ );
+ return columnsToSearch.some((field) =>
+ new RegExp(escapedSearchString, 'i').test(field.value.toString()),
+ );
+ }
+
+ /**
+ * Checks if the exact phrase exists in any of the log entry columns
+ * (excluding `severity`).
+ *
+ * @param {LogEntry} logEntry - The log entry to be searched.
+ * @param {string} exactPhrase - The exact phrase to search for within the
+ * log entry columns.
+ * @returns {boolean} True if the exact phrase is found in any column,
+ * otherwise false.
+ */
+ private static checkExactPhraseInColumns(
+ logEntry: LogEntry,
+ exactPhrase: string,
+ ): boolean {
+ const escapedExactPhrase = this.escapeRegEx(exactPhrase);
+ const searchRegex = new RegExp(escapedExactPhrase, 'i');
+ const columnsToSearch = logEntry.fields.filter(
+ (field) => field.key !== 'severity',
+ );
+ return columnsToSearch.some((field) =>
+ searchRegex.test(field.value.toString()),
+ );
+ }
+
+ private static escapeRegEx(text: string) {
+ return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
+ }
+}
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
new file mode 100644
index 000000000..63faa39a0
--- /dev/null
+++ b/pw_web/log-viewer/src/utils/log-filter/log-filter_test.ts
@@ -0,0 +1,173 @@
+// 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 { LogFilter } from './log-filter';
+import { Severity, LogEntry } from '../../shared/interfaces';
+import { describe, expect, test } from '@jest/globals';
+import testData from './test-data';
+
+describe('LogFilter', () => {
+ describe('parseSearchQuery()', () => {
+ describe('parses search queries correctly', () => {
+ testData.forEach(({ query, expected }) => {
+ test(`parses "${query}" correctly`, () => {
+ const filters = LogFilter.parseSearchQuery(query);
+ expect(filters).toEqual(expected);
+ });
+ });
+ });
+ });
+
+ describe('createFilterFunction()', () => {
+ describe('filters log entries correctly', () => {
+ const logEntry1: LogEntry = {
+ timestamp: new Date(),
+ severity: Severity.INFO,
+ fields: [
+ { key: 'source', value: 'application' },
+ {
+ key: 'message',
+ value: 'Request processed successfully!',
+ },
+ ],
+ };
+
+ const logEntry2: LogEntry = {
+ timestamp: new Date(),
+ severity: Severity.WARNING,
+ fields: [
+ { key: 'source', value: 'database' },
+ {
+ key: 'message',
+ value: 'Database connection lost. Attempting to reconnect.',
+ },
+ ],
+ };
+
+ const logEntry3: LogEntry = {
+ timestamp: new Date(),
+ severity: Severity.ERROR,
+ fields: [
+ { key: 'source', value: 'network' },
+ {
+ key: 'message',
+ value:
+ 'An unexpected error occurred while performing the operation.',
+ },
+ ],
+ };
+
+ const logEntries = [logEntry1, logEntry2, logEntry3];
+
+ test('should filter by simple string search', () => {
+ const searchQuery = 'error';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([logEntry3]);
+ });
+
+ test('should filter by column-specific search', () => {
+ const searchQuery = 'source:database';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([logEntry2]);
+ });
+
+ test('should filter by exact phrase', () => {
+ const searchQuery = '"Request processed successfully!"';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([logEntry1]);
+ });
+
+ test('should filter by column presence', () => {
+ const searchQuery = 'source:';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([
+ logEntry1,
+ logEntry2,
+ logEntry3,
+ ]);
+ });
+
+ test('should handle AND expressions', () => {
+ const searchQuery = 'source:network message:error';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([logEntry3]);
+ });
+
+ test('should handle OR expressions', () => {
+ const searchQuery = 'source:database | source:network';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([logEntry2, logEntry3]);
+ });
+
+ test('should handle NOT expressions', () => {
+ const searchQuery = '!source:database';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([logEntry1, logEntry3]);
+ });
+
+ test('should handle a combination of AND and OR expressions', () => {
+ const searchQuery = '(source:database | source:network) message:error';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([logEntry3]);
+ });
+
+ test('should handle a combination of AND, OR, and NOT expressions', () => {
+ const searchQuery =
+ '(source:application | source:database) !message:request';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([logEntry2]);
+ });
+
+ test('should handle an empty query', () => {
+ const searchQuery = '';
+ const filters = LogFilter.parseSearchQuery(searchQuery).map((node) =>
+ LogFilter.createFilterFunction(node),
+ );
+ expect(filters.length).toBe(1);
+ expect(logEntries.filter(filters[0])).toEqual([
+ logEntry1,
+ logEntry2,
+ logEntry3,
+ ]);
+ });
+ });
+ });
+});
diff --git a/pw_web/log-viewer/src/utils/log-filter/test-data.ts b/pw_web/log-viewer/src/utils/log-filter/test-data.ts
new file mode 100644
index 000000000..e7fd7c138
--- /dev/null
+++ b/pw_web/log-viewer/src/utils/log-filter/test-data.ts
@@ -0,0 +1,361 @@
+// 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 { ConditionType } from './log-filter.models';
+
+const testData = [
+ {
+ query: 'error',
+ expected: [
+ {
+ type: ConditionType.StringSearch,
+ searchString: 'error',
+ },
+ ],
+ },
+ {
+ query: 'source:database',
+ expected: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ ],
+ },
+ {
+ query: '"Request processed successfully!"',
+ expected: [
+ {
+ type: ConditionType.ExactPhraseSearch,
+ exactPhrase: 'Request processed successfully!',
+ },
+ ],
+ },
+ {
+ query: 'source:',
+ expected: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ },
+ ],
+ },
+ {
+ query: 'source:network message:error',
+ expected: [
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'network',
+ },
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'message',
+ value: 'error',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: 'source:database | source:network',
+ expected: [
+ {
+ type: ConditionType.OrExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'network',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: '!source:database',
+ expected: [
+ {
+ type: ConditionType.NotExpression,
+ expression: {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ },
+ ],
+ },
+ {
+ query: 'message:error (source:database | source:network)',
+ expected: [
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'message',
+ value: 'error',
+ },
+ {
+ type: ConditionType.OrExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'network',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: '(source:database | source:network) message:error',
+ expected: [
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.OrExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'network',
+ },
+ ],
+ },
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'message',
+ value: 'error',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: '(source:application | source:database) !message:request',
+ expected: [
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.OrExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'application',
+ },
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ ],
+ },
+ {
+ type: ConditionType.NotExpression,
+ expression: {
+ type: ConditionType.ColumnSearch,
+ column: 'message',
+ value: 'request',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: '',
+ expected: [
+ {
+ type: ConditionType.StringSearch,
+ searchString: '',
+ },
+ ],
+ },
+ {
+ // Note: AND takes priority over OR in evaluation.
+ query: 'source:database message:error | source:network message:error',
+ expected: [
+ {
+ type: ConditionType.OrExpression,
+ expressions: [
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'message',
+ value: 'error',
+ },
+ ],
+ },
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'network',
+ },
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'message',
+ value: 'error',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: 'source:database | error',
+ expected: [
+ {
+ type: ConditionType.OrExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ {
+ type: ConditionType.StringSearch,
+ searchString: 'error',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: 'source:application request',
+ expected: [
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'application',
+ },
+ {
+ type: ConditionType.StringSearch,
+ searchString: 'request',
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ query: 'source: application request',
+ expected: [
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ },
+ {
+ type: ConditionType.StringSearch,
+ searchString: 'application',
+ },
+ {
+ type: ConditionType.StringSearch,
+ searchString: 'request',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: 'source:network | (source:database lorem)',
+ expected: [
+ {
+ type: ConditionType.OrExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'network',
+ },
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.ColumnSearch,
+ column: 'source',
+ value: 'database',
+ },
+ {
+ type: ConditionType.StringSearch,
+ searchString: 'lorem',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ query: '"unexpected error" "the operation"',
+ expected: [
+ {
+ type: ConditionType.AndExpression,
+ expressions: [
+ {
+ type: ConditionType.ExactPhraseSearch,
+ exactPhrase: 'unexpected error',
+ },
+ {
+ type: ConditionType.ExactPhraseSearch,
+ exactPhrase: 'the operation',
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export default testData;
diff --git a/pw_web/log-viewer/src/utils/strings.ts b/pw_web/log-viewer/src/utils/strings.ts
new file mode 100644
index 000000000..0214c0287
--- /dev/null
+++ b/pw_web/log-viewer/src/utils/strings.ts
@@ -0,0 +1,22 @@
+// 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.
+
+export function titleCaseToKebabCase(input: string) {
+ return input
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ .replace(/-+/g, '-') // Replace consecutive hyphens with a single hyphen
+ .trim(); // Remove leading and trailing spaces
+}