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