1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00
lens/src/renderer/components/dock/log-list.tsx
Alex Andreev 83ed44f670
Adding logs tab bottom toolbar (#1951)
* Adding bottom toolbar to logs tab

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Making bottom toolbar responsive

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Using generic search input clear button

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Fixing log test selectors

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>
2021-01-15 11:34:11 +03:00

262 lines
7.6 KiB
TypeScript

import "./log-list.scss";
import React from "react";
import AnsiUp from "ansi_up";
import DOMPurify from "dompurify";
import debounce from "lodash/debounce";
import { action, computed, observable } from "mobx";
import { observer } from "mobx-react";
import { Align, ListOnScrollProps } from "react-window";
import { searchStore } from "../../../common/search-store";
import { cssNames } from "../../utils";
import { Button } from "../button";
import { Icon } from "../icon";
import { Spinner } from "../spinner";
import { VirtualList } from "../virtual-list";
import { podLogsStore } from "./log.store";
interface Props {
logs: string[]
isLoading: boolean
load: () => void
id: string
}
const colorConverter = new AnsiUp();
@observer
export class LogList extends React.Component<Props> {
@observable isJumpButtonVisible = false;
@observable isLastLineVisible = true;
private virtualListDiv = React.createRef<HTMLDivElement>(); // A reference for outer container in VirtualList
private virtualListRef = React.createRef<VirtualList>(); // A reference for VirtualList component
private lineHeight = 18; // Height of a log line. Should correlate with styles in pod-log-list.scss
componentDidMount() {
this.scrollToBottom();
}
componentDidUpdate(prevProps: Props) {
const { logs, id } = this.props;
if (id != prevProps.id) {
this.isLastLineVisible = true;
return;
}
if (logs == prevProps.logs || !this.virtualListDiv.current) return;
const newLogsLoaded = prevProps.logs.length < logs.length;
const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0;
if (this.isLastLineVisible || prevProps.logs.length == 0) {
this.scrollToBottom(); // Scroll down to keep user watching/reading experience
return;
}
if (scrolledToBeginning && newLogsLoaded) {
const firstLineContents = prevProps.logs[0];
const lineToScroll = this.props.logs.findIndex((value) => value == firstLineContents);
if (lineToScroll !== -1) {
this.scrollToItem(lineToScroll, "start");
}
}
if (!logs.length) {
this.isLastLineVisible = false;
}
}
/**
* Returns logs with or without timestamps regarding to showTimestamps prop
*/
@computed
get logs() {
const showTimestamps = podLogsStore.getData(this.props.id).showTimestamps;
if (!showTimestamps) {
return podLogsStore.logsWithoutTimestamps;
}
return this.props.logs;
}
/**
* 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.load();
}
};
@action
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) => {
if (!this.virtualListDiv.current) return;
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 } = 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 <span>
// 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
? <span
className={cssNames("overlay", { active })}
dangerouslySetInnerHTML={{ __html: ansiToHtml(overlayValue) }}
/>
: null;
contents.push(
<React.Fragment key={piece + index}>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(piece) }} />
{overlay}
</React.Fragment>
);
});
}
return (
<div className={cssNames("LogRow")}>
{contents.length > 1 ? contents : (
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(item) }} />
)}
{/* For preserving copy-paste experience and keeping line breaks */}
<br />
</div>
);
};
render() {
const { isLoading } = this.props;
const isInitLoading = isLoading && !this.logs.length;
const rowHeights = new Array(this.logs.length).fill(this.lineHeight);
if (isInitLoading) {
return (
<div className="LogList flex box grow align-center justify-center">
<Spinner center/>
</div>
);
}
if (!this.logs.length) {
return (
<div className="LogList flex box grow align-center justify-center">
There are no logs available for container
</div>
);
}
return (
<div className={cssNames("LogList flex", { isLoading })}>
<VirtualList
items={this.logs}
rowHeights={rowHeights}
getRow={this.getLogRow}
onScroll={this.onScroll}
outerRef={this.virtualListDiv}
ref={this.virtualListRef}
className="box grow"
/>
{this.isJumpButtonVisible && (
<JumpToBottom onClick={this.scrollToBottom} />
)}
</div>
);
}
}
interface JumpToBottomProps {
onClick: () => void
}
const JumpToBottom = ({ onClick }: JumpToBottomProps) => {
return (
<Button
primary
className="JumpToBottom flex gaps"
onClick={evt => {
evt.currentTarget.blur();
onClick();
}}
>
Jump to bottom
<Icon material="expand_more" />
</Button>
);
};