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