From 2a96e094bb7187c306c49d61cc7744c4942963e4 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Thu, 26 Nov 2020 14:11:54 +0300 Subject: [PATCH] Pod logs refactoring (#1516) * Spreading PodLogs into 2 components Signed-off-by: Alex Andreev * Removing pod-logs.scss Signed-off-by: Alex Andreev * Removing unused isScrollHidden param Signed-off-by: Alex Andreev * Cleaning up logs components Signed-off-by: Alex Andreev --- .../components/dock/pod-log-controls.scss | 5 + .../components/dock/pod-log-controls.tsx | 4 +- .../components/dock/pod-log-list.scss | 78 ++++++ src/renderer/components/dock/pod-log-list.tsx | 224 +++++++++++++++++ src/renderer/components/dock/pod-logs.scss | 86 ------- src/renderer/components/dock/pod-logs.tsx | 227 ++---------------- 6 files changed, 333 insertions(+), 291 deletions(-) create mode 100644 src/renderer/components/dock/pod-log-controls.scss create mode 100644 src/renderer/components/dock/pod-log-list.scss create mode 100644 src/renderer/components/dock/pod-log-list.tsx delete mode 100644 src/renderer/components/dock/pod-logs.scss diff --git a/src/renderer/components/dock/pod-log-controls.scss b/src/renderer/components/dock/pod-log-controls.scss new file mode 100644 index 0000000000..795d06c67e --- /dev/null +++ b/src/renderer/components/dock/pod-log-controls.scss @@ -0,0 +1,5 @@ +.PodLogControls { + .Select { + min-width: 150px; + } +} \ No newline at end of file diff --git a/src/renderer/components/dock/pod-log-controls.tsx b/src/renderer/components/dock/pod-log-controls.tsx index 17ad8a2ddf..bb4132dc34 100644 --- a/src/renderer/components/dock/pod-log-controls.tsx +++ b/src/renderer/components/dock/pod-log-controls.tsx @@ -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); diff --git a/src/renderer/components/dock/pod-log-list.scss b/src/renderer/components/dock/pod-log-list.scss new file mode 100644 index 0000000000..9c14f79fa4 --- /dev/null +++ b/src/renderer/components/dock/pod-log-list.scss @@ -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; + } + } +} diff --git a/src/renderer/components/dock/pod-log-list.tsx b/src/renderer/components/dock/pod-log-list.tsx new file mode 100644 index 0000000000..e882b38a5b --- /dev/null +++ b/src/renderer/components/dock/pod-log-list.tsx @@ -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 { + @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 + + 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 + // 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 : ( + + )} +
+ ); + }; + + render() { + const { logs, isLoading } = this.props; + const isInitLoading = isLoading && !logs.length; + const rowHeights = new Array(logs.length).fill(this.lineHeight); + if (isInitLoading) { + return ; + } + if (!logs.length) { + return ( +
+ There are no logs available for container +
+ ); + } + return ( +
+ + {this.isJumpButtonVisible && ( + + )} +
+ ); + } +} + +interface JumpToBottomProps { + onClick: () => void +} + +const JumpToBottom = ({ onClick }: JumpToBottomProps) => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/renderer/components/dock/pod-logs.scss b/src/renderer/components/dock/pod-logs.scss deleted file mode 100644 index 47909b4fb9..0000000000 --- a/src/renderer/components/dock/pod-logs.scss +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/src/renderer/components/dock/pod-logs.tsx b/src/renderer/components/dock/pod-logs.tsx index 1e7d7e4a14..71579be112 100644 --- a/src/renderer/components/dock/pod-logs.tsx +++ b/src/renderer/components/dock/pod-logs.tsx @@ -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 { - @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(); // A reference for outer container in VirtualList - private virtualListRef = React.createRef(); // A reference for VirtualList component - private lastLineIsShown = true; // used for proper auto-scroll content after refresh - private colorConverter = new AnsiUp(); + private logListElement = React.createRef(); // 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 { } 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 { @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 { 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 - // 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 : ( - - )} -
- ); - }; - - renderJumpToBottom() { - if (!this.logsElement) return null; - return ( - - ); - } - - 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 ; - } - if (!this.logs.length) { - return ( -
- There are no logs available for container. -
- ); - } - return ( - <> - {this.preloading && ( -
- -
- )} - - - ); - } - render() { - const { className } = this.props; const controls = ( { /> ); return ( -
+
-
- {this.renderJumpToBottom()} - {this.renderLogs()} -
+
); }