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, html } from 'lit'; 16import { customElement, property, query, state } from 'lit/decorators.js'; 17import { styles } from './log-view.styles'; 18import { LogList } from '../log-list/log-list'; 19import { TableColumn, LogEntry, SourceData } from '../../shared/interfaces'; 20import { LogFilter } from '../../utils/log-filter/log-filter'; 21import '../log-list/log-list'; 22import '../log-view-controls/log-view-controls'; 23import { downloadTextLogs } from '../../utils/download'; 24import { LogViewControls } from '../log-view-controls/log-view-controls'; 25 26type FilterFunction = (logEntry: LogEntry) => boolean; 27 28/** 29 * A component that filters and displays incoming log entries in an encapsulated 30 * instance. Each `LogView` contains a log list and a set of log view controls 31 * for configurable viewing of filtered logs. 32 * 33 * @element log-view 34 */ 35@customElement('log-view') 36export class LogView extends LitElement { 37 static styles = styles; 38 39 /** 40 * The component's global `id` attribute. This unique value is set whenever a 41 * view is created in a log viewer instance. 42 */ 43 @property({ type: String }) 44 id = ''; 45 46 /** An array of log entries to be displayed. */ 47 @property({ type: Array }) 48 logs: LogEntry[] = []; 49 50 /** Indicates whether this view is one of multiple instances. */ 51 @property({ type: Boolean }) 52 isOneOfMany = false; 53 54 /** The title of the log view, to be displayed on the log view toolbar */ 55 @property({ type: String }) 56 viewTitle = ''; 57 58 /** The field keys (column values) for the incoming log entries. */ 59 @property({ type: Array }) 60 columnData: TableColumn[] = []; 61 62 /** 63 * Flag to determine whether Shoelace components should be used by 64 * `LogView` and its subcomponents. 65 */ 66 @property({ type: Boolean }) 67 useShoelaceFeatures = true; 68 69 /** A string representing the value contained in the search field. */ 70 @property() 71 searchText = ''; 72 73 /** Whether line wrapping in table cells should be used. */ 74 @property() 75 lineWrap = true; 76 77 /** Preferred column order to reference */ 78 @state() 79 columnOrder: string[] = []; 80 81 @query('log-list') _logList!: LogList; 82 @query('log-view-controls') _logControls!: LogViewControls; 83 84 /** A map containing data from present log sources */ 85 sources: Map<string, SourceData> = new Map(); 86 87 /** 88 * An array containing the logs that remain after the current filter has been 89 * applied. 90 */ 91 private _filteredLogs: LogEntry[] = []; 92 93 /** A function used for filtering rows that contain a certain substring. */ 94 private _stringFilter: FilterFunction = () => true; 95 96 /** 97 * A function used for filtering rows that contain a timestamp within a 98 * certain window. 99 */ 100 private _timeFilter: FilterFunction = () => true; 101 102 private _debounceTimeout: NodeJS.Timeout | null = null; 103 104 /** The number of elements in the `logs` array since last updated. */ 105 private _lastKnownLogLength = 0; 106 107 /** The amount of time, in ms, before the filter expression is executed. */ 108 private readonly FILTER_DELAY = 100; 109 110 /** A default header title for the log view. */ 111 private readonly DEFAULT_VIEW_TITLE: string = 'Log View'; 112 113 protected firstUpdated(): void { 114 this.updateColumnOrder(this.columnData); 115 if (this.columnData) { 116 this.columnData = this.updateColumnRender(this.columnData); 117 } 118 } 119 120 updated(changedProperties: PropertyValues) { 121 super.updated(changedProperties); 122 123 if (changedProperties.has('logs')) { 124 const newLogs = this.logs.slice(this._lastKnownLogLength); 125 this._lastKnownLogLength = this.logs.length; 126 127 this.updateFieldsFromNewLogs(newLogs); 128 } 129 130 if (changedProperties.has('logs') || changedProperties.has('searchText')) { 131 this.filterLogs(); 132 } 133 134 if (changedProperties.has('columnData')) { 135 this._logList.columnData = [...this.columnData]; 136 this._logControls.columnData = [...this.columnData]; 137 } 138 } 139 140 /** 141 * Updates the log filter based on the provided event type. 142 * 143 * @param {CustomEvent} event - The custom event containing the information to 144 * update the filter. 145 */ 146 private updateFilter(event: CustomEvent) { 147 switch (event.type) { 148 case 'input-change': 149 this.searchText = event.detail.inputValue; 150 151 if (this._debounceTimeout) { 152 clearTimeout(this._debounceTimeout); 153 } 154 155 if (!this.searchText) { 156 this._stringFilter = () => true; 157 return; 158 } 159 160 // Run the filter after the timeout delay 161 this._debounceTimeout = setTimeout(() => { 162 const filters = LogFilter.parseSearchQuery(this.searchText).map( 163 (condition) => LogFilter.createFilterFunction(condition), 164 ); 165 this._stringFilter = 166 filters.length > 0 167 ? (logEntry: LogEntry) => 168 filters.some((filter) => filter(logEntry)) 169 : () => true; 170 171 this.filterLogs(); 172 }, this.FILTER_DELAY); 173 break; 174 case 'clear-logs': 175 this._timeFilter = (logEntry) => 176 logEntry.timestamp > event.detail.timestamp; 177 break; 178 default: 179 break; 180 } 181 this.filterLogs(); 182 } 183 184 private updateFieldsFromNewLogs(newLogs: LogEntry[]): void { 185 newLogs.forEach((log) => { 186 log.fields.forEach((field) => { 187 if (!this.columnData.some((col) => col.fieldName === field.key)) { 188 const newColumnData = { 189 fieldName: field.key, 190 characterLength: 0, 191 manualWidth: null, 192 isVisible: true, 193 }; 194 this.updateColumnOrder([newColumnData]); 195 this.columnData = this.updateColumnRender([ 196 newColumnData, 197 ...this.columnData, 198 ]); 199 } 200 }); 201 }); 202 } 203 204 /** 205 * Orders fields by the following: level, init defined fields, undefined fields, and message 206 * @param columnData ColumnData is used to check for undefined fields. 207 */ 208 private updateColumnOrder(columnData: TableColumn[]) { 209 const columnOrder = this.columnOrder; 210 if (this.columnOrder.length !== columnOrder.length) { 211 console.warn( 212 'Log View had duplicate columns defined, duplicates were removed.', 213 ); 214 this.columnOrder = columnOrder; 215 } 216 217 if (this.columnOrder.indexOf('level') != 0) { 218 const index = this.columnOrder.indexOf('level'); 219 if (index != -1) { 220 this.columnOrder.splice(index, 1); 221 } 222 this.columnOrder.unshift('level'); 223 } 224 225 if (this.columnOrder.indexOf('message') != this.columnOrder.length) { 226 const index = this.columnOrder.indexOf('message'); 227 if (index != -1) { 228 this.columnOrder.splice(index, 1); 229 } 230 this.columnOrder.push('message'); 231 } 232 233 columnData.forEach((tableColumn) => { 234 // Do not display severity in log views 235 if (tableColumn.fieldName == 'severity') { 236 console.warn( 237 'The field `severity` has been deprecated. Please use `level` instead.', 238 ); 239 return; 240 } 241 242 if (!this.columnOrder.includes(tableColumn.fieldName)) { 243 this.columnOrder.splice( 244 this.columnOrder.length - 1, 245 0, 246 tableColumn.fieldName, 247 ); 248 } 249 }); 250 } 251 252 /** 253 * Updates order of columnData based on columnOrder for log viewer to render 254 * @param columnData ColumnData to order 255 * @return Ordered list of ColumnData 256 */ 257 private updateColumnRender(columnData: TableColumn[]): TableColumn[] { 258 const orderedColumns: TableColumn[] = []; 259 const columnFields = columnData.map((column) => { 260 return column.fieldName; 261 }); 262 263 this.columnOrder.forEach((field: string) => { 264 const index = columnFields.indexOf(field); 265 if (index > -1) { 266 orderedColumns.push(columnData[index]); 267 } 268 }); 269 return orderedColumns; 270 } 271 272 public getFields(): string[] { 273 return this.columnData 274 .filter((column) => column.isVisible) 275 .map((column) => column.fieldName); 276 } 277 278 /** 279 * Toggles the visibility of columns in the log list based on the provided 280 * event. 281 * 282 * @param {CustomEvent} event - The click event containing the field being 283 * toggled. 284 */ 285 private toggleColumns(event: CustomEvent) { 286 // Find the relevant column in _columnData 287 const column = this.columnData.find( 288 (col) => col.fieldName === event.detail.field, 289 ); 290 291 if (!column) { 292 return; 293 } 294 295 // Toggle the column's visibility 296 column.isVisible = !event.detail.isChecked; 297 298 // Clear the manually-set width of the last visible column 299 const lastVisibleColumn = this.columnData 300 .slice() 301 .reverse() 302 .find((col) => col.isVisible); 303 if (lastVisibleColumn) { 304 lastVisibleColumn.manualWidth = null; 305 } 306 307 // Trigger a `columnData` update 308 this.columnData = [...this.columnData]; 309 } 310 311 /** 312 * Toggles the wrapping of text in each row. 313 * 314 * @param {CustomEvent} event - The click event. 315 */ 316 private toggleWrapping() { 317 this.lineWrap = !this.lineWrap; 318 } 319 320 /** 321 * Combines filter expressions and filters the logs. The filtered 322 * logs are stored in the `_filteredLogs` property. 323 */ 324 private filterLogs() { 325 const combinedFilter = (logEntry: LogEntry) => 326 this._timeFilter(logEntry) && this._stringFilter(logEntry); 327 328 const newFilteredLogs = this.logs.filter(combinedFilter); 329 330 if ( 331 JSON.stringify(newFilteredLogs) !== JSON.stringify(this._filteredLogs) 332 ) { 333 this._filteredLogs = newFilteredLogs; 334 } 335 this.requestUpdate(); 336 } 337 338 /** 339 * Generates a log file in the specified format and initiates its download. 340 * 341 * @param {CustomEvent} event - The click event. 342 */ 343 private downloadLogs(event: CustomEvent) { 344 const headers = this.columnData.map((column) => column.fieldName); 345 const viewTitle = event.detail.viewTitle; 346 downloadTextLogs(this.logs, headers, viewTitle); 347 } 348 349 render() { 350 return html` <log-view-controls 351 .viewId=${this.id} 352 .viewTitle=${this.viewTitle || this.DEFAULT_VIEW_TITLE} 353 .hideCloseButton=${!this.isOneOfMany} 354 .searchText=${this.searchText} 355 .useShoelaceFeatures=${this.useShoelaceFeatures} 356 .lineWrap=${this.lineWrap} 357 @input-change="${this.updateFilter}" 358 @clear-logs="${this.updateFilter}" 359 @column-toggle="${this.toggleColumns}" 360 @wrap-toggle="${this.toggleWrapping}" 361 @download-logs="${this.downloadLogs}" 362 > 363 </log-view-controls> 364 365 <log-list 366 .lineWrap=${this.lineWrap} 367 .viewId=${this.id} 368 .logs=${this._filteredLogs} 369 .searchText=${this.searchText} 370 > 371 </log-list>`; 372 } 373} 374