1
0
mirror of https://github.com/lensapp/lens.git synced 2024-09-20 13:57:23 +03:00

Pod logs refactoring (#1516)

* Spreading PodLogs into 2 components

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

* Removing pod-logs.scss

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

* Removing unused isScrollHidden param

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

* Cleaning up logs components

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
Alex Andreev 2020-11-26 14:11:54 +03:00 committed by GitHub
parent ccd38b5cbe
commit 2a96e094bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 333 additions and 291 deletions

View File

@ -0,0 +1,5 @@
.PodLogControls {
.Select {
min-width: 150px;
}
}

View File

@ -1,3 +1,4 @@
import "./pod-log-controls.scss";
import React from "react";
import { observer } from "mobx-react";
import { IPodLogsData, podLogsStore } from "./pod-logs.store";
@ -21,10 +22,9 @@ interface Props extends PodLogSearchProps {
}
export const PodLogControls = observer((props: Props) => {
if (!props.ready) return null;
const { tabData, save, reload, tabId, logs } = props;
const { selectedContainer, showTimestamps, previous } = tabData;
const rawLogs = podLogsStore.logs.get(tabId);
const rawLogs = podLogsStore.logs.get(tabId) || [];
const since = rawLogs.length ? podLogsStore.getTimestamps(rawLogs[0]) : null;
const pod = new Pod(tabData.pod);

View File

@ -0,0 +1,78 @@
.PodLogList {
--overlay-bg: #8cc474b8;
--overlay-active-bg: orange;
// fix for `this.logsElement.scrollTop = this.logsElement.scrollHeight`
// `overflow: overlay` don't allow scroll to the last line
overflow: auto;
position: relative;
color: $textColorAccent;
background: $logsBackground;
flex-grow: 1;
.VirtualList {
height: 100%;
.list {
overflow-x: scroll!important;
.LogRow {
padding: 2px 16px;
height: 18px; // Must be equal to lineHeight variable in pod-log-list.tsx
font-family: $font-monospace;
font-size: smaller;
white-space: pre;
&:hover {
background: $logRowHoverBackground;
}
span {
-webkit-font-smoothing: auto; // Better readability on non-retina screens
}
span.overlay {
border-radius: 2px;
-webkit-font-smoothing: auto;
background-color: var(--overlay-bg);
span {
background-color: var(--overlay-bg)!important; // Rewriting inline styles from AnsiUp library
}
&.active {
background-color: var(--overlay-active-bg);
span {
background-color: var(--overlay-active-bg)!important; // Rewriting inline styles from AnsiUp library
}
}
}
}
}
}
&.isLoading {
cursor: wait;
}
&.isScrollHidden {
.VirtualList .list {
overflow-x: hidden!important; // fixing scroll to bottom issues in PodLogs
}
}
.JumpToBottom {
position: absolute;
right: 30px;
padding: $unit / 2 $unit * 1.5;
border-radius: $unit * 2;
z-index: 2;
top: 20px;
.Icon {
--size: $unit * 2;
}
}
}

View File

@ -0,0 +1,224 @@
import "./pod-log-list.scss";
import React from "react";
import AnsiUp from "ansi_up";
import DOMPurify from "dompurify";
import debounce from "lodash/debounce";
import { Trans } from "@lingui/macro";
import { action, 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 { logRange } from "./pod-logs.store";
interface Props {
logs: string[]
isLoading: boolean
load: () => void
id: string
}
const colorConverter = new AnsiUp();
@observer
export class PodLogList 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;
const fewLogsLoaded = logs.length < logRange;
if (this.isLastLineVisible) {
this.scrollToBottom(); // Scroll down to keep user watching/reading experience
return;
}
if (scrolledToBeginning && newLogsLoaded) {
this.virtualListDiv.current.scrollTop = (logs.length - prevProps.logs.length) * this.lineHeight;
}
if (fewLogsLoaded) {
this.isJumpButtonVisible = false;
}
if (!logs.length) {
this.isLastLineVisible = false;
}
}
/**
* 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, scrollDirection } = props;
if (scrollDirection == "backward") {
this.isLastLineVisible = false;
} else {
if (clientHeight + scrollOffset === scrollHeight) {
this.isLastLineVisible = true;
}
}
};
/**
* 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.isJumpButtonVisible = false;
this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight;
};
scrollToItem = (index: number, align: Align) => {
this.virtualListRef.current.scrollToItem(index, align);
};
onScroll = 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.props.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) }} />
)}
</div>
);
};
render() {
const { logs, isLoading } = this.props;
const isInitLoading = isLoading && !logs.length;
const rowHeights = new Array(logs.length).fill(this.lineHeight);
if (isInitLoading) {
return <Spinner center/>;
}
if (!logs.length) {
return (
<div className="PodLogList flex box grow align-center justify-center">
<Trans>There are no logs available for container</Trans>
</div>
);
}
return (
<div className={cssNames("PodLogList flex", { isLoading })}>
<VirtualList
items={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();
}}
>
<Trans>Jump to bottom</Trans>
<Icon material="expand_more" />
</Button>
);
};

View File

@ -1,86 +0,0 @@
.PodLogs {
--overlay-bg: #8cc474b8;
--overlay-active-bg: orange;
.logs {
overflow: auto;
position: relative;
color: $textColorAccent;
background: $logsBackground;
flex-grow: 1;
.VirtualList {
height: 100%;
.list {
.LogRow {
padding: 2px 16px;
height: 18px; // Must be equal to lineHeight variable in pod-logs.scss
font-family: $font-monospace;
font-size: smaller;
white-space: pre;
&:hover {
background: $logRowHoverBackground;
}
span {
-webkit-font-smoothing: auto; // Better readability on non-retina screens
}
span.overlay {
border-radius: 2px;
-webkit-font-smoothing: auto;
background-color: var(--overlay-bg);
span {
background-color: var(--overlay-bg)!important; // Rewriting inline styles from AnsiUp library
}
&.active {
background-color: var(--overlay-active-bg);
span {
background-color: var(--overlay-active-bg)!important; // Rewriting inline styles from AnsiUp library
}
}
}
}
}
}
}
.jump-to-bottom {
position: absolute;
right: 30px;
padding: $unit / 2 $unit * 1.5;
border-radius: $unit * 2;
opacity: 0;
z-index: 2;
top: 20px;
&.active {
opacity: 1;
}
.Icon {
--size: $unit * 2;
}
}
.PodLogControls {
.Select {
min-width: 150px;
}
}
.logs .VirtualList .list {
overflow-x: scroll!important;
}
&.noscroll {
.logs .VirtualList .list {
overflow-x: hidden!important; // fixing scroll to bottom issues in PodLogs
}
}
}

View File

@ -1,65 +1,30 @@
import "./pod-logs.scss";
import React from "react";
import AnsiUp from 'ansi_up';
import DOMPurify from "dompurify";
import { Trans } from "@lingui/macro";
import { action, computed, observable, reaction } from "mobx";
import { computed, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { _i18n } from "../../i18n";
import { autobind, cssNames } from "../../utils";
import { Icon } from "../icon";
import { Spinner } from "../spinner";
import { searchStore } from "../../../common/search-store";
import { autobind } from "../../utils";
import { IDockTab } from "./dock.store";
import { InfoPanel } from "./info-panel";
import { IPodLogsData, logRange, podLogsStore } from "./pod-logs.store";
import { Button } from "../button";
import { PodLogControls } from "./pod-log-controls";
import { VirtualList } from "../virtual-list";
import { searchStore } from "../../../common/search-store";
import { ListOnScrollProps } from "react-window";
import { PodLogList } from "./pod-log-list";
import { IPodLogsData, podLogsStore } from "./pod-logs.store";
interface Props {
className?: string
tab: IDockTab
}
const lineHeight = 18; // Height of a log line. Should correlate with styles in pod-logs.scss
@observer
export class PodLogs extends React.Component<Props> {
@observable ready = false;
@observable preloading = false; // Indicator for setting Spinner (loader) at the top of the logs
@observable showJumpToBottom = false;
@observable hideHorizontalScroll = true; // Hiding scrollbar allows to scroll logs down to last element
@observable isLoading = true;
private logsElement = React.createRef<HTMLDivElement>(); // A reference for outer container in VirtualList
private virtualListRef = React.createRef<VirtualList>(); // A reference for VirtualList component
private lastLineIsShown = true; // used for proper auto-scroll content after refresh
private colorConverter = new AnsiUp();
private logListElement = React.createRef<PodLogList>(); // A reference for VirtualList component
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.tab.id, async () => {
await this.load();
this.scrollToBottom();
}, { fireImmediately: true }),
// Check if need to show JumpToBottom if new log amount is less than previous one
reaction(() => podLogsStore.logs.get(this.tabId), () => {
const { tabId } = this;
if (podLogsStore.logs.has(tabId) && podLogsStore.logs.get(tabId).length < logRange) {
this.showJumpToBottom = false;
}
})
]);
}
componentDidUpdate() {
// scroll logs only when it's already in the end,
// otherwise it can interrupt reading by jumping after loading new logs update
if (this.logsElement.current && this.lastLineIsShown) {
this.logsElement.current.scrollTop = this.logsElement.current.scrollHeight;
}
disposeOnUnmount(this,
reaction(() => this.props.tab.id, this.reload, { fireImmediately: true })
);
}
get tabData() {
@ -76,33 +41,16 @@ export class PodLogs extends React.Component<Props> {
}
load = async () => {
this.ready = false;
this.isLoading = true;
await podLogsStore.load(this.tabId);
this.ready = true;
this.isLoading = false;
};
reload = async () => {
podLogsStore.clearLogs(this.tabId);
this.lastLineIsShown = true;
await this.load();
};
/**
* Function loads more logs (usually after user scrolls to top) and sets proper
* scrolling position
*/
loadMore = async () => {
const lines = podLogsStore.lines;
if (lines < logRange) return;
this.preloading = true;
await podLogsStore.load(this.tabId);
this.preloading = false;
if (podLogsStore.lines > lines) {
// Set scroll position back to place where preloading started
this.logsElement.current.scrollTop = (podLogsStore.lines - lines) * lineHeight;
}
};
/**
* A function for various actions after search is happened
* @param query {string} A text from search field
@ -118,9 +66,9 @@ export class PodLogs extends React.Component<Props> {
@autobind()
toOverlay() {
const { activeOverlayLine } = searchStore;
if (!this.virtualListRef.current || activeOverlayLine === undefined) return;
if (!this.logListElement.current || activeOverlayLine === undefined) return;
// Scroll vertically
this.virtualListRef.current.scrollToItem(activeOverlayLine, "center");
this.logListElement.current.scrollToItem(activeOverlayLine, "center");
// Scroll horizontally in timeout since virtual list need some time to prepare its contents
setTimeout(() => {
const overlay = document.querySelector(".PodLogs .list span.active");
@ -145,140 +93,10 @@ export class PodLogs extends React.Component<Props> {
return logs;
}
onScroll = (props: ListOnScrollProps) => {
if (!this.logsElement.current) return;
const toBottomOffset = 100 * lineHeight; // 100 lines * 18px (height of each line)
const { scrollHeight, clientHeight } = this.logsElement.current;
const { scrollDirection, scrollOffset, scrollUpdateWasRequested } = props;
if (scrollDirection == "forward") {
if (scrollHeight - scrollOffset < toBottomOffset) {
this.showJumpToBottom = false;
}
if (clientHeight + scrollOffset === scrollHeight) {
this.lastLineIsShown = true;
}
} else {
this.lastLineIsShown = false;
// Trigger loading only if scrolled by user
if (scrollOffset === 0 && !scrollUpdateWasRequested) {
this.loadMore();
}
if (scrollHeight - scrollOffset > toBottomOffset) {
this.showJumpToBottom = true;
}
}
};
@action
scrollToBottom = () => {
if (!this.virtualListRef.current) return;
this.hideHorizontalScroll = true;
this.virtualListRef.current.scrollToItem(this.logs.length, "end");
this.showJumpToBottom = false;
// Showing horizontal scrollbar after VirtualList settles down
setTimeout(() => this.hideHorizontalScroll = false, 500);
};
/**
* A function is called by VirtualList for rendering each of the row
* @param rowIndex {Number} 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(this.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) }} />
)}
</div>
);
};
renderJumpToBottom() {
if (!this.logsElement) return null;
return (
<Button
primary
className={cssNames("jump-to-bottom flex gaps", {active: this.showJumpToBottom})}
onClick={evt => {
evt.currentTarget.blur();
this.scrollToBottom();
}}
>
<Trans>Jump to bottom</Trans>
<Icon material="expand_more" />
</Button>
);
}
renderLogs() {
// Generating equal heights for each row with ability to do multyrow logs in future
// e. g. for wrapping logs feature
const rowHeights = new Array(this.logs.length).fill(lineHeight);
if (!this.ready) {
return <Spinner center/>;
}
if (!this.logs.length) {
return (
<div className="flex box grow align-center justify-center">
<Trans>There are no logs available for container.</Trans>
</div>
);
}
return (
<>
{this.preloading && (
<div className="flex justify-center">
<Spinner center />
</div>
)}
<VirtualList
items={this.logs}
rowHeights={rowHeights}
getRow={this.getLogRow}
onScroll={this.onScroll}
outerRef={this.logsElement}
ref={this.virtualListRef}
className="box grow"
/>
</>
);
}
render() {
const { className } = this.props;
const controls = (
<PodLogControls
ready={this.ready}
ready={!this.isLoading}
tabId={this.tabId}
tabData={this.tabData}
logs={this.logs}
@ -290,17 +108,20 @@ export class PodLogs extends React.Component<Props> {
/>
);
return (
<div className={cssNames("PodLogs flex column", className, { noscroll: this.hideHorizontalScroll })}>
<div className="PodLogs flex column">
<InfoPanel
tabId={this.props.tab.id}
controls={controls}
showSubmitClose={false}
showButtons={false}
/>
<div className="logs flex">
{this.renderJumpToBottom()}
{this.renderLogs()}
</div>
<PodLogList
id={this.tabId}
isLoading={this.isLoading}
logs={this.logs}
load={this.load}
ref={this.logListElement}
/>
</div>
);
}