xref: /aosp_15_r20/external/pigweed/pw_web/log-viewer/src/components/log-view/log-view.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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