xref: /aosp_15_r20/external/pigweed/pw_web/log-viewer/src/components/log-viewer.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1// Copyright 2024 The Pigweed Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License"); you may not
4// use this file except in compliance with the License. You may obtain a copy of
5// the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12// License for the specific language governing permissions and limitations under
13// the License.
14
15import { LitElement, PropertyValues, TemplateResult, html } from 'lit';
16import { customElement, property, queryAll, state } from 'lit/decorators.js';
17import {
18  LogEntry,
19  LogSourceEvent,
20  SourceData,
21  TableColumn,
22} from '../shared/interfaces';
23import {
24  LocalStateStorage,
25  LogViewerState,
26  StateService,
27} from '../shared/state';
28import { ViewNode, NodeType } from '../shared/view-node';
29import { styles } from './log-viewer.styles';
30import { themeDark } from '../themes/dark';
31import { themeLight } from '../themes/light';
32import { LogView } from './log-view/log-view';
33import { LogSource } from '../log-source';
34import { LogStore } from '../log-store';
35import CloseViewEvent from '../events/close-view';
36import SplitViewEvent from '../events/split-view';
37import InputChangeEvent from '../events/input-change';
38import WrapToggleEvent from '../events/wrap-toggle';
39import ColumnToggleEvent from '../events/column-toggle';
40import ResizeColumnEvent from '../events/resize-column';
41
42type ColorScheme = 'dark' | 'light';
43
44/**
45 * The root component which renders one or more log views for displaying
46 * structured log entries.
47 *
48 * @element log-viewer
49 */
50@customElement('log-viewer')
51export class LogViewer extends LitElement {
52  static styles = [styles, themeDark, themeLight];
53
54  logStore: LogStore;
55
56  /** An array of log entries to be displayed. */
57  @property({ type: Array })
58  logs: LogEntry[] = [];
59
60  @property({ type: Array })
61  logSources: LogSource[] | LogSource = [];
62
63  @property({ type: String, reflect: true })
64  colorScheme?: ColorScheme;
65
66  /**
67   * Flag to determine whether Shoelace components should be used by
68   * `LogViewer` and its subcomponents.
69   */
70  @property({ type: Boolean })
71  useShoelaceFeatures = true;
72
73  @state()
74  _rootNode: ViewNode;
75
76  /** An array that stores the preferred column order of columns  */
77  @state()
78  private _columnOrder: string[] = ['log_source', 'time', 'timestamp'];
79
80  @queryAll('log-view') logViews!: LogView[];
81
82  /** A map containing data from present log sources */
83  private _sources: Map<string, SourceData> = new Map();
84
85  private _sourcesArray: LogSource[] = [];
86
87  private _lastUpdateTimeoutId: NodeJS.Timeout | undefined;
88
89  private _stateService: StateService = new StateService(
90    new LocalStateStorage(),
91  );
92
93  /**
94   * Create a log-viewer
95   * @param logSources - Collection of sources from where logs originate
96   * @param options - Optional parameters to change default settings
97   * @param options.columnOrder - defines column order between level and
98   *   message undefined fields are added between defined order and message.
99   * @param options.state - handles state between sessions, defaults to localStorage
100   */
101  constructor(
102    logSources: LogSource[] | LogSource,
103    options?: {
104      columnOrder?: string[] | undefined;
105      logStore?: LogStore | undefined;
106      state?: LogViewerState | undefined;
107    },
108  ) {
109    super();
110
111    this.logSources = logSources;
112    this.logStore = options?.logStore ?? new LogStore();
113    this.logStore.setColumnOrder(this._columnOrder);
114
115    const savedState = options?.state ?? this._stateService.loadState();
116    this._rootNode =
117      savedState?.rootNode || new ViewNode({ type: NodeType.View });
118    if (options?.columnOrder) {
119      this._columnOrder = [...new Set(options?.columnOrder)];
120    }
121    this.loadShoelaceComponents();
122  }
123
124  logEntryListener = (event: LogSourceEvent) => {
125    if (event.type === 'log-entry') {
126      const logEntry = event.data;
127      this.logStore.addLogEntry(logEntry);
128      this.logs = this.logStore.getLogs();
129
130      if (this._lastUpdateTimeoutId) {
131        clearTimeout(this._lastUpdateTimeoutId);
132      }
133
134      // Call requestUpdate at most once every 100 milliseconds.
135      this._lastUpdateTimeoutId = setTimeout(() => {
136        this.logs = [...this.logStore.getLogs()];
137      }, 100);
138    }
139  };
140
141  connectedCallback() {
142    super.connectedCallback();
143    this.addEventListener('close-view', this.handleCloseView);
144
145    this._sourcesArray = Array.isArray(this.logSources)
146      ? this.logSources
147      : [this.logSources];
148    this._sourcesArray.forEach((logSource: LogSource) => {
149      logSource.addEventListener('log-entry', this.logEntryListener);
150    });
151
152    // If color scheme isn't set manually, retrieve it from localStorage
153    if (!this.colorScheme) {
154      const storedScheme = localStorage.getItem(
155        'colorScheme',
156      ) as ColorScheme | null;
157      if (storedScheme) {
158        this.colorScheme = storedScheme;
159      }
160    }
161  }
162
163  firstUpdated() {
164    this.delSevFromState(this._rootNode);
165  }
166
167  updated(changedProperties: PropertyValues) {
168    super.updated(changedProperties);
169
170    if (changedProperties.has('colorScheme') && this.colorScheme) {
171      // Only store in localStorage if color scheme is 'dark' or 'light'
172      if (this.colorScheme === 'light' || this.colorScheme === 'dark') {
173        localStorage.setItem('colorScheme', this.colorScheme);
174      } else {
175        localStorage.removeItem('colorScheme');
176      }
177    }
178
179    if (changedProperties.has('logs')) {
180      this.logs.forEach((logEntry) => {
181        if (logEntry.sourceData && !this._sources.has(logEntry.sourceData.id)) {
182          this._sources.set(logEntry.sourceData.id, logEntry.sourceData);
183        }
184      });
185    }
186  }
187
188  disconnectedCallback() {
189    super.disconnectedCallback();
190    this.removeEventListener('close-view', this.handleCloseView);
191
192    this._sourcesArray.forEach((logSource: LogSource) => {
193      logSource.removeEventListener('log-entry', this.logEntryListener);
194    });
195
196    // Save state before disconnecting
197    this._stateService.saveState({ rootNode: this._rootNode });
198  }
199
200  /**
201   * Conditionally loads Shoelace components
202   */
203  async loadShoelaceComponents() {
204    if (this.useShoelaceFeatures) {
205      await import(
206        '@shoelace-style/shoelace/dist/components/split-panel/split-panel.js'
207      );
208    }
209  }
210
211  private splitLogView(event: SplitViewEvent) {
212    const { parentId, orientation, columnData, searchText, viewTitle } =
213      event.detail;
214
215    // Find parent node, handle errors if not found
216    const parentNode = this.findNodeById(this._rootNode, parentId);
217    if (!parentNode) {
218      console.error('Parent node not found for split:', parentId);
219      return;
220    }
221
222    // Create `ViewNode`s with inherited or provided data
223    const newView = new ViewNode({
224      type: NodeType.View,
225      logViewId: crypto.randomUUID(),
226      columnData: JSON.parse(
227        JSON.stringify(columnData || parentNode.logViewState?.columnData),
228      ),
229      searchText: searchText || parentNode.logViewState?.searchText,
230      viewTitle: viewTitle || parentNode.logViewState?.viewTitle,
231    });
232
233    // Both views receive the same values for `searchText` and `columnData`
234    const originalView = new ViewNode({
235      type: NodeType.View,
236      logViewId: crypto.randomUUID(),
237      columnData: JSON.parse(JSON.stringify(newView.logViewState?.columnData)),
238      searchText: newView.logViewState?.searchText,
239    });
240
241    parentNode.type = NodeType.Split;
242    parentNode.orientation = orientation;
243    parentNode.children = [originalView, newView];
244
245    this._stateService.saveState({ rootNode: this._rootNode });
246
247    this.requestUpdate();
248  }
249
250  private findNodeById(node: ViewNode, id: string): ViewNode | undefined {
251    if (node.logViewId === id) {
252      return node;
253    }
254
255    // Recursively search through children `ViewNode`s for a match
256    for (const child of node.children) {
257      const found = this.findNodeById(child, id);
258      if (found) {
259        return found;
260      }
261    }
262    return undefined;
263  }
264
265  /**
266   * Removes a log view when its Close button is clicked.
267   *
268   * @param event The event object dispatched by the log view controls.
269   */
270  private handleCloseView(event: CloseViewEvent) {
271    const viewId = event.detail.viewId;
272
273    const removeViewNode = (node: ViewNode, id: string): boolean => {
274      let nodeIsFound = false;
275
276      node.children.forEach((child, index) => {
277        if (nodeIsFound) return;
278
279        if (child.logViewId === id) {
280          node.children.splice(index, 1); // Remove the targeted view
281          if (node.children.length === 1) {
282            // Flatten the node if only one child remains
283            const remainingChild = node.children[0];
284            Object.assign(node, remainingChild);
285          }
286          nodeIsFound = true;
287        } else {
288          nodeIsFound = removeViewNode(child, id);
289        }
290      });
291      return nodeIsFound;
292    };
293
294    if (removeViewNode(this._rootNode, viewId)) {
295      this._stateService.saveState({ rootNode: this._rootNode });
296    }
297
298    this.requestUpdate();
299  }
300
301  private handleViewEvent(
302    event:
303      | InputChangeEvent
304      | ColumnToggleEvent
305      | ResizeColumnEvent
306      | WrapToggleEvent,
307  ) {
308    const { viewId } = event.detail;
309    const nodeToUpdate = this.findNodeById(this._rootNode, viewId);
310
311    if (!nodeToUpdate) {
312      return;
313    }
314
315    if (event.type === 'wrap-toggle') {
316      const { isChecked } = (event as WrapToggleEvent).detail;
317      if (nodeToUpdate.logViewState) {
318        nodeToUpdate.logViewState.wordWrap = isChecked;
319        this._stateService.saveState({ rootNode: this._rootNode });
320      }
321    }
322
323    if (event.type === 'input-change') {
324      const { inputValue } = (event as InputChangeEvent).detail;
325      if (nodeToUpdate.logViewState) {
326        nodeToUpdate.logViewState.searchText = inputValue;
327        this._stateService.saveState({ rootNode: this._rootNode });
328      }
329    }
330
331    if (event.type === 'resize-column' || event.type === 'column-toggle') {
332      const { columnData } = (event as ResizeColumnEvent).detail;
333      if (nodeToUpdate.logViewState) {
334        nodeToUpdate.logViewState.columnData = columnData;
335        this._stateService.saveState({ rootNode: this._rootNode });
336      }
337    }
338  }
339
340  /**
341   * Handles case if switching from level -> severity -> level, state will be
342   * restructured to remove severity and move up level if it exists.
343   *
344   * @param node The state node.
345   */
346  private delSevFromState(node: ViewNode) {
347    if (node.logViewState?.columnData) {
348      const fields = node.logViewState?.columnData.map(
349        (field) => field.fieldName,
350      );
351
352      if (fields?.includes('level')) {
353        const index = fields.indexOf('level');
354        if (index !== 0) {
355          const level = node.logViewState?.columnData[index] as TableColumn;
356          node.logViewState?.columnData.splice(index, 1);
357          node.logViewState?.columnData.unshift(level);
358        }
359      }
360
361      if (fields?.includes('severity')) {
362        const index = fields.indexOf('severity');
363        node.logViewState?.columnData.splice(index, 1);
364      }
365    }
366
367    if (node.type === 'split') {
368      node.children.forEach((child) => this.delSevFromState(child));
369    }
370  }
371
372  private renderNodes(node: ViewNode): TemplateResult {
373    if (node.type === NodeType.View || !this.useShoelaceFeatures) {
374      return html`<log-view
375        id=${node.logViewId ?? ''}
376        .logs=${this.logs}
377        .sources=${this._sources}
378        .isOneOfMany=${this._rootNode.children.length > 1}
379        .columnOrder=${this._columnOrder}
380        .searchText=${node.logViewState?.searchText ?? ''}
381        .columnData=${node.logViewState?.columnData ?? []}
382        .viewTitle=${node.logViewState?.viewTitle || ''}
383        .lineWrap=${node.logViewState?.wordWrap ?? true}
384        .useShoelaceFeatures=${this.useShoelaceFeatures}
385        @split-view="${this.splitLogView}"
386        @input-change="${this.handleViewEvent}"
387        @wrap-toggle="${this.handleViewEvent}"
388        @resize-column="${this.handleViewEvent}"
389        @column-toggle="${this.handleViewEvent}"
390      ></log-view>`;
391    } else {
392      const [startChild, endChild] = node.children;
393      return html`<sl-split-panel ?vertical=${node.orientation === 'vertical'}>
394        ${startChild
395          ? html`<div slot="start">${this.renderNodes(startChild)}</div>`
396          : ''}
397        ${endChild
398          ? html`<div slot="end">${this.renderNodes(endChild)}</div>`
399          : ''}
400      </sl-split-panel>`;
401    }
402  }
403
404  render() {
405    return html`${this.renderNodes(this._rootNode)}`;
406  }
407}
408
409// Manually register Log View component due to conditional rendering
410if (!customElements.get('log-view')) {
411  customElements.define('log-view', LogView);
412}
413
414declare global {
415  interface HTMLElementTagNameMap {
416    'log-viewer': LogViewer;
417  }
418}
419