Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pod logs refactoring #1516

Merged
merged 6 commits into from Nov 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/renderer/components/dock/pod-log-controls.scss
@@ -0,0 +1,5 @@
.PodLogControls {
.Select {
min-width: 150px;
}
}
4 changes: 2 additions & 2 deletions 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";
Expand All @@ -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);

Expand Down
78 changes: 78 additions & 0 deletions 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;
}
}
}
224 changes: 224 additions & 0 deletions 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<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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field really sounds like it is computed. Shouldn't it be marked as such?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. This is an observable flag not really different from other ones like isJumpButtonVisible. A computed is used to calculate some value from other observables. isLastLineVisible just holds bool value and doesn't need to be "computed".

}
}

/**
* 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>
);
};