• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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