• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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, state } from 'lit/decorators.js';
17import { LogEntry, SourceData } from '../shared/interfaces';
18import {
19  LocalStateStorage,
20  LogViewerState,
21  StateService,
22} from '../shared/state';
23import { ViewNode, NodeType } from '../shared/view-node';
24import { styles } from './log-viewer.styles';
25import { themeDark } from '../themes/dark';
26import { themeLight } from '../themes/light';
27import { LogView } from './log-view/log-view';
28import CloseViewEvent from '../events/close-view';
29import SplitViewEvent from '../events/split-view';
30import InputChangeEvent from '../events/input-change';
31import ColumnToggleEvent from '../events/column-toggle';
32import ResizeColumnEvent from '../events/resize-column';
33
34type ColorScheme = 'dark' | 'light';
35
36/**
37 * The root component which renders one or more log views for displaying
38 * structured log entries.
39 *
40 * @element log-viewer
41 */
42@customElement('log-viewer')
43export class LogViewer extends LitElement {
44  static styles = [styles, themeDark, themeLight];
45
46  /** An array of log entries to be displayed. */
47  @property({ type: Array })
48  logs: LogEntry[] = [];
49
50  @property({ type: String, reflect: true })
51  colorScheme?: ColorScheme;
52
53  @state()
54  _rootNode: ViewNode;
55
56  /** An array that stores the preferred column order of columns  */
57  @state()
58  _columnOrder: string[];
59
60  /** A map containing data from present log sources */
61  private _sources: Map<string, SourceData> = new Map();
62
63  private _stateService: StateService = new StateService(
64    new LocalStateStorage(),
65  );
66
67  constructor(state: LogViewerState | undefined, columnOrder: string[]) {
68    super();
69    this._columnOrder = columnOrder;
70    const savedState = state ?? this._stateService.loadState();
71    this._rootNode =
72      savedState?.rootNode || new ViewNode({ type: NodeType.View });
73  }
74
75  connectedCallback() {
76    super.connectedCallback();
77    this.addEventListener('close-view', this.handleCloseView);
78
79    // If color scheme isn't set manually, retrieve it from localStorage
80    if (!this.colorScheme) {
81      const storedScheme = localStorage.getItem(
82        'colorScheme',
83      ) as ColorScheme | null;
84      if (storedScheme) {
85        this.colorScheme = storedScheme;
86      }
87    }
88  }
89
90  updated(changedProperties: PropertyValues) {
91    super.updated(changedProperties);
92
93    if (changedProperties.has('colorScheme') && this.colorScheme) {
94      // Only store in localStorage if color scheme is 'dark' or 'light'
95      if (this.colorScheme === 'light' || this.colorScheme === 'dark') {
96        localStorage.setItem('colorScheme', this.colorScheme);
97      } else {
98        localStorage.removeItem('colorScheme');
99      }
100    }
101
102    if (changedProperties.has('logs')) {
103      this.logs.forEach((logEntry) => {
104        if (logEntry.sourceData && !this._sources.has(logEntry.sourceData.id)) {
105          this._sources.set(logEntry.sourceData.id, logEntry.sourceData);
106        }
107      });
108    }
109  }
110
111  disconnectedCallback() {
112    super.disconnectedCallback();
113    this.removeEventListener('close-view', this.handleCloseView);
114
115    // Save state before disconnecting
116    this._stateService.saveState({ rootNode: this._rootNode });
117  }
118
119  private splitLogView(event: SplitViewEvent) {
120    const { parentId, orientation, columnData, searchText } = event.detail;
121
122    // Find parent node, handle errors if not found
123    const parentNode = this.findNodeById(this._rootNode, parentId);
124    if (!parentNode) {
125      console.error('Parent node not found for split:', parentId);
126      return;
127    }
128
129    // Create `ViewNode`s with inherited or provided data
130    const newView = new ViewNode({
131      type: NodeType.View,
132      logViewId: crypto.randomUUID(),
133      columnData: JSON.parse(
134        JSON.stringify(columnData || parentNode.logViewState?.columnData),
135      ),
136      searchText: searchText || parentNode.logViewState?.searchText,
137    });
138
139    // Both views receive the same values for `searchText` and `columnData`
140    const originalView = new ViewNode({
141      type: NodeType.View,
142      logViewId: crypto.randomUUID(),
143      columnData: JSON.parse(JSON.stringify(newView.logViewState?.columnData)),
144      searchText: newView.logViewState?.searchText,
145    });
146
147    parentNode.type = NodeType.Split;
148    parentNode.orientation = orientation;
149    parentNode.children = [originalView, newView];
150
151    this._stateService.saveState({ rootNode: this._rootNode });
152
153    this.requestUpdate();
154  }
155
156  private findNodeById(node: ViewNode, id: string): ViewNode | undefined {
157    if (node.logViewId === id) {
158      return node;
159    }
160
161    // Recursively search through children `ViewNode`s for a match
162    for (const child of node.children) {
163      const found = this.findNodeById(child, id);
164      if (found) {
165        return found;
166      }
167    }
168    return undefined;
169  }
170
171  /**
172   * Removes a log view when its Close button is clicked.
173   *
174   * @param event The event object dispatched by the log view controls.
175   */
176  private handleCloseView(event: CloseViewEvent) {
177    const viewId = event.detail.viewId;
178
179    const removeViewNode = (node: ViewNode, id: string): boolean => {
180      let nodeIsFound = false;
181
182      node.children.forEach((child, index) => {
183        if (nodeIsFound) return;
184
185        if (child.logViewId === id) {
186          node.children.splice(index, 1); // Remove the targeted view
187          if (node.children.length === 1) {
188            // Flatten the node if only one child remains
189            const remainingChild = node.children[0];
190            Object.assign(node, remainingChild);
191          }
192          nodeIsFound = true;
193        } else {
194          nodeIsFound = removeViewNode(child, id);
195        }
196      });
197      return nodeIsFound;
198    };
199
200    if (removeViewNode(this._rootNode, viewId)) {
201      this._stateService.saveState({ rootNode: this._rootNode });
202    }
203
204    this.requestUpdate();
205  }
206
207  private handleViewEvent(
208    event: InputChangeEvent | ColumnToggleEvent | ResizeColumnEvent,
209  ) {
210    const { viewId } = event.detail;
211    const nodeToUpdate = this.findNodeById(this._rootNode, viewId);
212
213    if (!nodeToUpdate) {
214      return;
215    }
216
217    if (event.type === 'input-change') {
218      const { inputValue } = (event as InputChangeEvent).detail;
219      if (nodeToUpdate.logViewState) {
220        nodeToUpdate.logViewState.searchText = inputValue;
221      }
222      return;
223    } else if (event.type === 'resize-column') {
224      const { columnData } = (event as ResizeColumnEvent).detail;
225      if (nodeToUpdate.logViewState) {
226        nodeToUpdate.logViewState.columnData = columnData;
227      }
228    }
229
230    this._stateService.saveState({ rootNode: this._rootNode });
231  }
232
233  private renderNodes(node: ViewNode): TemplateResult {
234    if (node.type === NodeType.View) {
235      return html`<log-view
236        id=${node.logViewId ?? ''}
237        .logs=${this.logs}
238        .sources=${this._sources}
239        .isOneOfMany=${this._rootNode.children.length > 1}
240        .columnOrder=${this._columnOrder}
241        .searchText=${node.logViewState?.searchText ?? ''}
242        .columnData=${node.logViewState?.columnData ?? []}
243        @split-view="${this.splitLogView}"
244        @input-change="${this.handleViewEvent}"
245        @column-toggle="${this.handleViewEvent}"
246        @resize-column="${this.handleViewEvent}"
247      ></log-view>`;
248    } else {
249      const [startChild, endChild] = node.children;
250      return html`<sl-split-panel ?vertical=${node.orientation === 'vertical'}>
251        ${startChild
252          ? html`<div slot="start">${this.renderNodes(startChild)}</div>`
253          : ''}
254        ${endChild
255          ? html`<div slot="end">${this.renderNodes(endChild)}</div>`
256          : ''}
257      </sl-split-panel>`;
258    }
259  }
260
261  render() {
262    return html`${this.renderNodes(this._rootNode)}`;
263  }
264}
265
266// Manually register Log View component due to conditional rendering
267if (!customElements.get('log-view')) {
268  customElements.define('log-view', LogView);
269}
270
271declare global {
272  interface HTMLElementTagNameMap {
273    'log-viewer': LogViewer;
274  }
275}
276