Skip to content

Commit

Permalink
feat: Log Output modifications (#976)
Browse files Browse the repository at this point in the history
* feat: extract logs streaming logic to hook
* chore: extract useClientRect hook / extract common props
* chore: extract AnimatedFullscreenLogOutput
* feat: add option to hide action buttons in Log Output
* fix: simplify LogOutput DOM structure
* feat: add option to wrap log output
* feat: add option to modify line in log output
   - wrap each line with this component
   - get rid of ansi-to-react
   - add some optimizations
* chore: extract LogOutput logic to hook
* feat: expose more powerful references from LogOutput/Console
* feat: use basic virtual rendering for the log output
* fix: detect proper virtual width of Console
* fix: wrapping lines
* fix: line number stickiness
* fix: scrolling to beginning of line
* fix: virtual rendering while watching
* fix: wrapping lines
* feat: expose option to get line rect in Console
* chore: clean LogOutputPure a little
* feat: extract log parsing logic to the LogProcessor
* feat: optimize console, split logprocessor
* fix: OSS log output
* feat: avoid full screen animation in oss
* chore: extract logic for calculating console dimensions
* chore: clean up Console code
* chore: use `debounce` from lodash
* chore: delete fullscreen animation code for log output
* fix: allow empty last callback
* feat: extract Toolbar from LogOutput to separate components
* chore: extract the scrollbar out of the Log Output
* feat: extract keyword scanner / fix: memory leak
* feat: add basic search functionality to Log Output
* fix: some detected bugs
  • Loading branch information
rangoo94 authored Dec 15, 2023
1 parent df55203 commit b7ac8a1
Show file tree
Hide file tree
Showing 39 changed files with 1,521 additions and 345 deletions.
22 changes: 5 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@
"@sentry/integrations": "^7.64.0",
"@sentry/react": "^7.64.0",
"@testkube/plugins": "*",
"ansi-to-react": "^6.1.6",
"anser": "^2.1.1",
"antd": "^4.24.12",
"axios": "0.27.2",
"classnames": "2.3.1",
"cron-parser": "^4.8.1",
"date-fns": "^2.28.0",
"escape-carriage": "^1.3.1",
"file-saver": "^2.0.5",
"framer-motion": "^4.1.17",
"lodash.debounce": "^4.0.8",
Expand Down
2 changes: 1 addition & 1 deletion packages/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="preload"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Roboto+Mono&display=swap"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=IBM+Plex+Mono&display=swap"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/antd-theme/my-antd-theme.less
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@layout-body-background: #111827;
@layout-header-background: #111827;
@font-family: 'Roboto', sans-serif;
@code-family: 'Roboto Mono', sans-serif;
@code-family: 'IBM Plex Mono', monospace;
// button
@btn-height-base: 40px;
@btn-default-bg: rgba(255, 255, 255, 0.05);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const lineHeight = 22;

const options = {
contextmenu: true,
fontFamily: 'Roboto Mono, Monaco, monospace',
fontFamily: '"IBM Plex Mono", Monaco, monospace',
fontSize: 13,
lineHeight,
minimap: {
Expand Down
80 changes: 80 additions & 0 deletions packages/web/src/components/molecules/Console/Console.styled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import styled, {css} from 'styled-components';

import AnsiClassesMapping from '@atoms/TestkubeTheme/AnsiClassesMapping';

import Colors from '@styles/Colors';
import {invisibleScroll} from '@styles/globalStyles';

export const Container = styled.code<{$wrap?: boolean}>`
display: block;
width: 100%;
height: 100%;
overflow: auto;
${({$wrap}) =>
$wrap
? `
word-break: break-all;
white-space: break-spaces;`
: ''}
${AnsiClassesMapping}
${invisibleScroll}
`;

export const Content = styled.div`
width: min-content;
min-width: 100%;
`;

export const Space = styled.div`
width: 100%;
`;

export const Line = styled.div<{$highlighted: boolean}>`
position: relative;
width: 100%;
${({$highlighted}) => ($highlighted ? `background: rgb(255 255 255 / 5%)` : '')}
`;

export const Keyword = styled.span`
box-shadow: inset 0 -2px 0 0 ${Colors.indigo400};
`;

const hiddenCss = css`
visibility: hidden;
opacity: 0;
user-select: none;
pointer-events: none;
overflow: hidden;
`;

export const PlaceholderContainer = styled.div`
display: none;
${hiddenCss}
`;

export const Monitor = styled.iframe`
border: 0;
width: 100%;
height: 100%;
`;

export const HeightMonitor = styled.div`
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
${hiddenCss}
`;

export const WidthMonitor = styled.div`
position: absolute;
left: 0;
top: 0;
right: 0;
height: 0;
${hiddenCss}
`;
183 changes: 183 additions & 0 deletions packages/web/src/components/molecules/Console/Console.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {
FC,
PropsWithChildren,
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {usePrevious, useUpdate} from 'react-use';

import {escapeCarriageReturn} from 'escape-carriage';
import {debounce} from 'lodash';

import {useEventCallback} from '@hooks/useEventCallback';
import {useLastCallback} from '@hooks/useLastCallback';

import * as S from './Console.styled';
import {ConsoleLine} from './ConsoleLine';
import {ConsoleLineDimensions, ConsoleLineMonitor} from './ConsoleLineMonitor';
import {ConsoleLines} from './ConsoleLines';
import {LogProcessor} from './LogProcessor';
import {useLogLinesPosition} from './useLogLinesPosition';

export interface ConsoleProps {
wrap?: boolean;
content: string;
start?: number;
LineComponent?: FC<PropsWithChildren<{number: number; maxDigits: number}>>;
}

export interface ConsoleRef {
container: HTMLDivElement | null;
getVisualLinesCount: () => number;
getLineRect: (line: number) => {top: number; height: number};
scrollToStart: () => void;
scrollToEnd: () => void;
scrollToLine: (line: number) => void;
isScrolledToStart: () => boolean;
isScrolledToEnd: () => boolean;
}

// TODO: Optimize to process only newly added content
export const Console = forwardRef<ConsoleRef, ConsoleProps>(({content, wrap, LineComponent = ConsoleLine}, ref) => {
const processor = useMemo(() => LogProcessor.from(escapeCarriageReturn(content)), [content]);
const containerRef = useRef<HTMLDivElement | null>(null);
const maxDigits = processor.maxDigits;

const rerender = useUpdate();

const [{baseWidth, characterWidth, maxCharacters, lineHeight}, setDimensions] = useState<ConsoleLineDimensions>({
baseWidth: 0,
characterWidth: 1000,
maxCharacters: Infinity,
lineHeight: 1000,
lines: 0,
});

const {getTop, getSize, getVisualLine, total} = useLogLinesPosition(processor, maxCharacters);
const getTopPx = (line: number) => lineHeight * getTop(line);
const getCenterPx = (line: number) => getTopPx(line) + (getSize(line) * lineHeight) / 2;

const getViewportTop = useLastCallback(() => Math.floor((containerRef.current?.scrollTop || 0) / lineHeight));
const getViewportHeight = useLastCallback(() => Math.ceil((containerRef.current?.clientHeight || 0) / lineHeight));
const getViewport = () => {
const prerender = Math.max(Math.round(getViewportHeight() / 2), 30);
const viewportStart = Math.max(getViewportTop() - prerender, 0);
const viewportEnd = Math.min(viewportStart + getViewportHeight() + 2 * prerender, total - 1);
return {start: viewportStart, end: viewportEnd};
};
const getViewportLast = () => Math.ceil(Math.min(1 + getViewportTop() + getViewportHeight(), total));

// Keep information about line width
const maxCharactersCount = useMemo(() => processor.getMaxLineLength(), [processor]);
const minWidth = wrap ? 0 : baseWidth + characterWidth * maxCharactersCount;

// Compute current position
const {start: viewportStart, end: viewportEnd} = getViewport();
const {index: start, start: visualStart} = useMemo(
() => getVisualLine(viewportStart + 1),
[processor, maxCharacters, viewportStart]
);
const {index: end, end: visualEnd} = useMemo(
() => getVisualLine(viewportEnd + 1),
[processor, maxCharacters, viewportEnd]
);

const displayed = useMemo(() => processor.getProcessedLines(start, end + 1), [processor, start, end]);

const beforeCount = visualStart;
const afterCount = total - visualEnd;
const beforePx = Math.max(0, Math.floor(beforeCount * lineHeight));
const afterPx = Math.max(0, Math.floor(afterCount * lineHeight));
const styleTop = useMemo(() => ({height: `${beforePx}px`, width: `${minWidth}px`}), [beforePx, minWidth]);
const styleBottom = useMemo(() => ({height: `${afterPx}px`}), [afterPx]);

const scrollToLine = useLastCallback((line: number) => {
containerRef.current?.scrollTo(0, getCenterPx(line) - containerRef.current?.clientHeight / 2);
});
const getLineRect = useLastCallback((line: number) => ({
top: getTop(line),
height: getSize(line),
}));
const isScrolledToEnd = useLastCallback(() => {
const last = getViewportLast();
return last === 1 || last === total;
});
const scrollToEnd = () => {
if (containerRef.current) {
containerRef.current.scrollTo(0, containerRef.current?.scrollHeight);
}
};
const getVisualLinesCount = useLastCallback(() => total);

useImperativeHandle(
ref,
() => ({
get container() {
return containerRef.current;
},
isScrolledToStart() {
return containerRef.current?.scrollTop === 0;
},
scrollToStart: () => {
if (containerRef.current) {
containerRef.current.scrollTo(0, 0);
}
},
scrollToEnd,
isScrolledToEnd,
scrollToLine,
getLineRect,
getVisualLinesCount,
}),
[]
);

// Keep the scroll position
const clientHeight = containerRef.current?.clientHeight || 0;
const domScrollTop = containerRef.current?.scrollTop || 0;
const scrollTop = Math.min(domScrollTop, total * lineHeight - clientHeight);
useLayoutEffect(() => {
containerRef.current!.scrollTop = scrollTop;
}, [scrollTop]);

// Scroll to bottom after logs change
const scrolledToEnd = getViewportLast() >= (usePrevious(total) || 0);
useEffect(() => {
if (scrolledToEnd) {
scrollToEnd();
}
}, [content]);

// Inform about position change
// FIXME
useEffect(() => {
const t = setTimeout(() => containerRef.current?.dispatchEvent(new Event('reposition')), 1);
return () => clearTimeout(t);
}, [viewportStart, viewportEnd, scrollTop, clientHeight, total]);

// Re-render on scroll
const rerenderDebounce = useMemo(() => debounce(rerender, 5), []);
const onScroll = () => {
const viewport = getViewport();
if (viewport.start !== viewportStart || viewport.end !== viewportEnd) {
rerenderDebounce();
}
};
useEventCallback('scroll', onScroll, containerRef?.current);

return (
<S.Container $wrap={wrap} ref={containerRef}>
<S.Content>
<ConsoleLineMonitor Component={LineComponent} maxDigits={maxDigits} wrap={wrap} onChange={setDimensions} />
<S.Space style={styleTop} />
<ConsoleLines lines={displayed} start={start} maxDigits={maxDigits} LineComponent={LineComponent} />
<S.Space style={styleBottom} />
</S.Content>
</S.Container>
);
});
23 changes: 23 additions & 0 deletions packages/web/src/components/molecules/Console/ConsoleLine.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {FC, PropsWithChildren, memo} from 'react';

import {SearchResult, useLogOutput} from '@store/logOutput';

import * as S from './Console.styled';
import {Highlight} from './Highlight';

const EMPTY: SearchResult[] = [];

export const ConsoleLine: FC<PropsWithChildren<{number: number}>> = memo(({number, children}) => {
const {queryLength, results, selected} = useLogOutput(x => ({
queryLength: x.searchQuery.length,
results: x.searchLinesMap[number] || EMPTY,
selected: x.searchResults[x.searchIndex]?.line === number,
}));

return (
<S.Line $highlighted={selected}>
<Highlight highlights={queryLength === 0 ? undefined : results}>{children}</Highlight>
{'\n'}
</S.Line>
);
});
Loading

0 comments on commit b7ac8a1

Please sign in to comment.