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