blob: 8bdbc0d17d7a4358f544bda68a2017e8e801a7a7 [file] [log] [blame]
// Copyright 2023 The Pigweed Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.
import { LitElement, PropertyValues, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { styles } from './log-view.styles';
import { LogList } from '../log-list/log-list';
import { LogColumnState, LogEntry, State } from '../../shared/interfaces';
import { LocalStorageState, StateStore } from '../../shared/state';
import { LogFilter } from '../../utils/log-filter/log-filter';
import '../log-list/log-list';
import '../log-view-controls/log-view-controls';
import { titleCaseToKebabCase } from '../../utils/strings';
type FilterFunction = (logEntry: LogEntry) => boolean;
/**
* A component that filters and displays incoming log entries in an encapsulated
* instance. Each `LogView` contains a log list and a set of log view controls
* for configurable viewing of filtered logs.
*
* @element log-view
*/
@customElement('log-view')
export class LogView extends LitElement {
static styles = styles;
/**
* The component's global `id` attribute. This unique value is set whenever
* a view is created in a log viewer instance.
*/
@property({ type: String })
id = `${this.localName}-${crypto.randomUUID()}`;
/** An array of log entries to be displayed. */
@property({ type: Array })
logs: LogEntry[] = [];
/** Indicates whether this view is one of multiple instances. */
@property({ type: Boolean })
isOneOfMany = false;
/** Whether line wrapping in table cells should be used. */
@state()
_lineWrap = false;
/**
* An array containing the logs that remain after the current filter has
* been applied.
*/
@state()
private _filteredLogs: LogEntry[] = [];
/** The field keys (column values) for the incoming log entries. */
@state()
private _fieldKeys: string[] = [];
/** A function used for filtering rows that contain a certain substring. */
@state()
private _stringFilter: FilterFunction = () => true;
/**
* A function used for filtering rows that contain a timestamp within a
* certain window.
*/
@state()
private _timeFilter: FilterFunction = () => true;
/** A string representing the value contained in the search field. */
@state()
public searchText = '';
/** A StateStore object that stores state of views */
@state()
_stateStore: StateStore = new LocalStorageState();
@state()
_state: State;
@state()
_colsHidden: (boolean | undefined)[] = [];
@query('log-list') _logList!: LogList;
private _debounceTimeout: NodeJS.Timeout | null = null;
/** The amount of time, in ms, before the filter expression is executed. */
private readonly FILTER_DELAY = 100;
constructor() {
super();
this._state = this._stateStore.getState();
}
protected firstUpdated(): void {
this._colsHidden = [];
if (this._state) {
const viewConfigArr = this._state.logViewConfig;
const index = viewConfigArr.findIndex((i) => this.id === i.viewID);
if (index !== -1) {
viewConfigArr[index].search = this.searchText;
viewConfigArr[index].columns.map((i: LogColumnState) => {
this._colsHidden.push(i.hidden);
});
this._colsHidden.unshift(undefined);
}
}
}
updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has('logs')) {
this._fieldKeys = this.getFieldsFromLogs(this.logs);
this.filterLogs();
}
}
/**
* Updates the log filter based on the provided event type.
*
* @param {CustomEvent} event - The custom event containing the information
* to update the filter.
*/
private updateFilter(event: CustomEvent) {
this.searchText = event.detail.inputValue;
const viewConfigArr = this._state.logViewConfig;
const index = viewConfigArr.findIndex((i) => this.id === i.viewID);
switch (event.type) {
case 'input-change':
if (this._debounceTimeout) {
clearTimeout(this._debounceTimeout);
}
if (index !== -1) {
viewConfigArr[index].search = this.searchText;
this._state = { logViewConfig: viewConfigArr };
this._stateStore.setState({ logViewConfig: viewConfigArr });
}
if (!this.searchText) {
this._stringFilter = () => true;
return;
}
// Run the filter after the timeout delay
this._debounceTimeout = setTimeout(() => {
const filters = LogFilter.parseSearchQuery(this.searchText).map(
(condition) => LogFilter.createFilterFunction(condition),
);
this._stringFilter =
filters.length > 0
? (logEntry: LogEntry) =>
filters.some((filter) => filter(logEntry))
: () => true;
this.filterLogs();
this.requestUpdate();
}, this.FILTER_DELAY);
break;
case 'clear-logs':
this._timeFilter = (logEntry) =>
logEntry.timestamp > event.detail.timestamp;
break;
default:
break;
}
this.filterLogs();
this.requestUpdate();
}
/**
* Retrieves the field keys from the first entry in the log array.
*
* @param {LogEntry[]} logs - The array of log entries from which to
* retrieve the field keys.
* @returns {string[]} An array containing the field keys from the log
* entries.
*/
public getFieldsFromLogs(logs: LogEntry[]): string[] {
const logEntry = logs[0];
const logFields = [] as string[];
if (logEntry != undefined) {
logEntry.fields.forEach((field) => {
logFields.push(field.key);
});
}
return logFields.filter((field) => field !== 'severity');
}
/**
* Toggles the visibility of columns in the log list based on the provided
* event.
*
* @param {CustomEvent} event - The click event containing the field being
* toggled.
*/
private toggleColumns(event: CustomEvent) {
const viewConfigArr = this._state.logViewConfig;
let colIndex = -1;
this._colsHidden = [];
const index = viewConfigArr
.map((i) => {
return i.viewID;
})
.indexOf(this.id);
viewConfigArr[index].columns.map((i: LogColumnState) => {
this._colsHidden.push(i.hidden);
});
this._colsHidden.unshift(undefined);
this._fieldKeys.forEach((field: string, i: number) => {
if (field == event.detail.field) {
colIndex = i;
viewConfigArr[index].columns[colIndex].hidden = !event.detail.isChecked;
}
});
this._colsHidden[colIndex + 1] = !event.detail.isChecked; // Exclude first column (severity)
this._logList.colsHidden = [...this._colsHidden];
this._state = { logViewConfig: viewConfigArr };
this._stateStore.setState({ logViewConfig: viewConfigArr });
}
/**
* Toggles the wrapping of text in each row.
*
* @param {CustomEvent} event - The click event.
*/
private toggleWrapping() {
this._lineWrap = !this._lineWrap;
}
/**
* Combines constituent filter expressions and filters the logs. The
* filtered logs are stored in the `_filteredLogs` state property.
*/
private filterLogs() {
const combinedFilter = (logEntry: LogEntry) =>
this._timeFilter(logEntry) && this._stringFilter(logEntry);
this._filteredLogs = JSON.parse(
JSON.stringify(this.logs.filter(combinedFilter)),
);
}
/**
* Generates a log file in the specified format and initiates its download.
*
* @param {CustomEvent} event - The click event.
*/
private downloadLogs(event: CustomEvent) {
const headers = this.logs[0]?.fields.map((field) => field.key) || [];
const maxWidths = headers.map((header) => header.length);
const viewTitle = event.detail.viewTitle;
const fileName = viewTitle ? titleCaseToKebabCase(viewTitle) : 'logs';
this.logs.forEach((log) => {
log.fields.forEach((field, columnIndex) => {
maxWidths[columnIndex] = Math.max(
maxWidths[columnIndex],
field.value.toString().length,
);
});
});
const headerRow = headers
.map((header, columnIndex) => header.padEnd(maxWidths[columnIndex]))
.join('\t');
const separator = '';
const logRows = this.logs.map((log) => {
const values = log.fields.map((field, columnIndex) =>
field.value.toString().padEnd(maxWidths[columnIndex]),
);
return values.join('\t');
});
const formattedLogs = [headerRow, separator, ...logRows].join('\n');
const blob = new Blob([formattedLogs], { type: 'text/plain' });
const downloadLink = document.createElement('a');
downloadLink.href = URL.createObjectURL(blob);
downloadLink.download = `${fileName}.txt`;
downloadLink.click();
URL.revokeObjectURL(downloadLink.href);
}
render() {
return html` <log-view-controls
.colsHidden=${[...this._colsHidden]}
.viewId=${this.id}
.fieldKeys=${this._fieldKeys}
.hideCloseButton=${!this.isOneOfMany}
.stateStore=${this._stateStore}
@input-change="${this.updateFilter}"
@clear-logs="${this.updateFilter}"
@column-toggle="${this.toggleColumns}"
@wrap-toggle="${this.toggleWrapping}"
@download-logs="${this.downloadLogs}"
role="toolbar"
>
</log-view-controls>
<log-list
.colsHidden=${[...this._colsHidden]}
.lineWrap=${this._lineWrap}
.viewId=${this.id}
.logs=${this._filteredLogs}
.searchText=${this.searchText}
>
</log-list>`;
}
}