/** * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ import "./list.scss"; import React from "react"; import AnsiUp from "ansi_up"; import DOMPurify from "dompurify"; import debounce from "lodash/debounce"; import { action, computed, observable, makeObservable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import moment from "moment-timezone"; import type { Align, ListOnScrollProps } from "react-window"; import { SearchStore } from "../../../search-store/search-store"; import { UserStore } from "../../../../common/user-store"; import { array, boundMethod, cssNames } from "../../../utils"; import { VirtualList } from "../../virtual-list"; import { ToBottom } from "./to-bottom"; import type { LogTabViewModel } from "../logs/logs-view-model"; export interface LogListProps { model: LogTabViewModel; } const colorConverter = new AnsiUp(); @observer export class LogList extends React.Component { @observable isJumpButtonVisible = false; @observable isLastLineVisible = true; private virtualListDiv = React.createRef(); // A reference for outer container in VirtualList private virtualListRef = React.createRef(); // A reference for VirtualList component private lineHeight = 18; // Height of a log line. Should correlate with styles in pod-log-list.scss constructor(props: LogListProps) { super(props); makeObservable(this); } componentDidMount() { disposeOnUnmount(this, [ reaction(() => this.props.model.logs.get(), (logs, prevLogs) => { this.onLogsInitialLoad(logs, prevLogs); this.onLogsUpdate(); this.onUserScrolledUp(logs, prevLogs); }), ]); } @boundMethod onLogsInitialLoad(logs: string[], prevLogs: string[]) { if (!prevLogs.length && logs.length) { this.isLastLineVisible = true; } } @boundMethod onLogsUpdate() { if (this.isLastLineVisible) { setTimeout(() => { this.scrollToBottom(); }, 500); // Giving some time to VirtualList to prepare its outerRef (this.virtualListDiv) element } } @boundMethod onUserScrolledUp(logs: string[], prevLogs: string[]) { if (!this.virtualListDiv.current) return; const newLogsAdded = prevLogs.length < logs.length; const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0; if (newLogsAdded && scrolledToBeginning) { const firstLineContents = prevLogs[0]; const lineToScroll = logs.findIndex((value) => value == firstLineContents); if (lineToScroll !== -1) { this.scrollToItem(lineToScroll, "start"); } } } /** * Returns logs with or without timestamps regarding to showTimestamps prop */ @computed get logs(): string[] { const { showTimestamps } = this.props.model.logTabData.get(); if (!showTimestamps) { return this.props.model.logsWithoutTimestamps.get(); } return this.props.model.timestampSplitLogs .get() .map(([logTimestamp, log]) => (`${logTimestamp && moment.tz(logTimestamp, UserStore.getInstance().localeTimezone).format()}${log}`)); } /** * Checks if JumpToBottom button should be visible and sets its observable * @param props Scrolling props from virtual list core */ @action setButtonVisibility = (props: ListOnScrollProps) => { const offset = 100 * this.lineHeight; const { scrollHeight } = this.virtualListDiv.current; const { scrollOffset } = props; if (scrollHeight - scrollOffset < offset) { this.isJumpButtonVisible = false; } else { this.isJumpButtonVisible = true; } }; /** * Checks if last log line considered visible to user, setting its observable * @param props Scrolling props from virtual list core */ @action setLastLineVisibility = (props: ListOnScrollProps) => { const { scrollHeight, clientHeight } = this.virtualListDiv.current; const { scrollOffset } = props; this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight; }; /** * Check if user scrolled to top and new logs should be loaded * @param props Scrolling props from virtual list core */ checkLoadIntent = (props: ListOnScrollProps) => { const { scrollOffset } = props; if (scrollOffset === 0) { this.props.model.loadLogs(); } }; scrollToBottom = () => { if (!this.virtualListDiv.current) return; this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight; }; scrollToItem = (index: number, align: Align) => { this.virtualListRef.current.scrollToItem(index, align); }; onScroll = (props: ListOnScrollProps) => { this.isLastLineVisible = false; this.onScrollDebounced(props); }; onScrollDebounced = debounce((props: ListOnScrollProps) => { if (!this.virtualListDiv.current) return; this.setButtonVisibility(props); this.setLastLineVisibility(props); this.checkLoadIntent(props); }, 700); // Increasing performance and giving some time for virtual list to settle down /** * A function is called by VirtualList for rendering each of the row * @param rowIndex index of the log element in logs array * @returns A react element with a row itself */ getLogRow = (rowIndex: number) => { const { searchQuery, isActiveOverlay } = this.props.model.searchStore; const item = this.logs[rowIndex]; const contents: React.ReactElement[] = []; const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); if (searchQuery) { // If search is enabled, replace keyword with backgrounded // Case-insensitive search (lowercasing query and keywords in line) const regex = new RegExp(SearchStore.escapeRegex(searchQuery), "gi"); const matches = item.matchAll(regex); const modified = item.replace(regex, match => match.toLowerCase()); // Splitting text line by keyword const pieces = modified.split(searchQuery.toLowerCase()); pieces.forEach((piece, index) => { const active = isActiveOverlay(rowIndex, index); const lastItem = index === pieces.length - 1; const overlayValue = matches.next().value; const overlay = !lastItem ? : null; contents.push( {overlay} , ); }); } return (
{contents.length > 1 ? contents : ( )} {/* For preserving copy-paste experience and keeping line breaks */}
); }; render() { const rowHeights = array.filled(this.logs.length, this.lineHeight); if (!this.logs.length) { return (
There are no logs available for container
); } return (
{this.isJumpButtonVisible && ( )}
); } }