Skip to content

Commit

Permalink
feat: logs v2
Browse files Browse the repository at this point in the history
  • Loading branch information
devcatalin committed Mar 28, 2024
1 parent 209906b commit fee7969
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 59 deletions.
64 changes: 7 additions & 57 deletions packages/web/src/components/molecules/LogOutput/LogOutput.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,14 @@
import React, {Fragment, createElement, memo, useEffect, useRef} from 'react';
import {createPortal} from 'react-dom';
import React, {memo} from 'react';

import {useSearch} from '@molecules/LogOutput/useSearch';
import {isFeatureEnabled} from '@src/utils/apiInfo';

import {useTestsSlot} from '@plugins/tests-and-test-suites/hooks';

import {useLogOutputField, useLogOutputPick, useLogOutputSync} from '@store/logOutput';

import FullscreenLogOutput from './FullscreenLogOutput';
import {LogOutputWrapper} from './LogOutput.styled';
import LogOutputPure, {LogOutputPureRef} from './LogOutputPure';
import {LogOutputProps, useLogOutput} from './useLogOutput';
import LogOutputV1 from './LogOutputV1';
import LogOutputV2 from './LogOutputV2';
import {LogOutputProps} from './useLogOutput';

const LogOutput: React.FC<LogOutputProps> = props => {
const {isRunning} = props;

const logRef = useRef<LogOutputPureRef>(null);
const options = useLogOutput(props);
const {isFullscreen} = useLogOutputPick('isFullscreen');
const fullscreenContainer = document.querySelector('#log-output-container')!;

// Search logic
const [, setSearching] = useLogOutputField('searching');
const [searchQuery] = useLogOutputField('searchQuery');

useEffect(() => {
if (!searchQuery) {
setSearching(false);
}
}, [searchQuery, setSearching]);

const search = useSearch({searchQuery, output: options.logs});
useLogOutputSync({
searching: search.loading,
searchResults: search.list,
searchLinesMap: search.map,
});

const [searchIndex, setSearchIndex] = useLogOutputField('searchIndex');
useEffect(() => {
if (search.list.length === 0) {
// Do nothing
} else if (searchIndex >= search.list.length) {
setSearchIndex(0);
} else {
const highlight = search.list[searchIndex];
logRef.current?.console?.scrollToLine(highlight.line);
}
}, [searchIndex, searchQuery, search.loading, logRef.current?.console]);

return (
<>
<LogOutputWrapper>
{/* eslint-disable-next-line react/no-array-index-key */}
{useTestsSlot('logOutputTop').map((element, i) => createElement(Fragment, {key: i}, element))}
<LogOutputPure ref={logRef} isRunning={isRunning} {...options} />
</LogOutputWrapper>
{isFullscreen ? createPortal(<FullscreenLogOutput {...options} />, fullscreenContainer) : null}
</>
);
const isV2 = isFeatureEnabled('logsV2');
return <>{isV2 ? <LogOutputV2 {...props} /> : <LogOutputV1 {...props} />}</>;
};

export default memo(LogOutput);
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {CopyButton, DownloadButton} from '@atoms';
import useLocation from '@hooks/useLocation';
import useSecureContext from '@hooks/useSecureContext';

import {isFeatureEnabled} from '@src/utils/apiInfo';

import FullscreenAction from './FullscreenAction';
import {StyledLogOutputActionsContainer} from './LogOutput.styled';
import SearchAction from './SearchAction';
Expand All @@ -21,15 +23,18 @@ const LogOutputActions: React.FC<LogOutputActionsProps> = props => {
const isSecureContext = useSecureContext();
const filename = useLocation().lastPathSegment;

const isV2 = isFeatureEnabled('logsV2');

return (
<StyledLogOutputActionsContainer>
<SearchAction />
{isV2 ? null : <SearchAction />}
{isSecureContext ? (
<CopyButton content={strippedLogOutput} />
) : (
<DownloadButton filename={filename} extension="log" content={strippedLogOutput} />
)}
<FullscreenAction key="fullscreen-log-action" />

{isV2 ? null : <FullscreenAction key="fullscreen-log-action" />}
</StyledLogOutputActionsContainer>
);
};
Expand Down
63 changes: 63 additions & 0 deletions packages/web/src/components/molecules/LogOutput/LogOutputV1.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {Fragment, createElement, memo, useEffect, useRef} from 'react';
import {createPortal} from 'react-dom';

import {useTestsSlot} from '@plugins/tests-and-test-suites/hooks';

import {useLogOutputField, useLogOutputPick, useLogOutputSync} from '@store/logOutput';

import FullscreenLogOutput from './FullscreenLogOutput';
import {LogOutputWrapper} from './LogOutput.styled';
import LogOutputPure, {LogOutputPureRef} from './LogOutputPure';
import {LogOutputProps, useLogOutput} from './useLogOutput';
import {useSearch} from './useSearch';

const LogOutputV1 = (props: LogOutputProps) => {
const {isRunning} = props;

const logRef = useRef<LogOutputPureRef>(null);
const options = useLogOutput(props);
const {isFullscreen} = useLogOutputPick('isFullscreen');
const fullscreenContainer = document.querySelector('#log-output-container')!;

// Search logic
const [, setSearching] = useLogOutputField('searching');
const [searchQuery] = useLogOutputField('searchQuery');

useEffect(() => {
if (!searchQuery) {
setSearching(false);
}
}, [searchQuery, setSearching]);

const search = useSearch({searchQuery, output: options.logs});
useLogOutputSync({
searching: search.loading,
searchResults: search.list,
searchLinesMap: search.map,
});

const [searchIndex, setSearchIndex] = useLogOutputField('searchIndex');
useEffect(() => {
if (search.list.length === 0) {
// Do nothing
} else if (searchIndex >= search.list.length) {
setSearchIndex(0);
} else {
const highlight = search.list[searchIndex];
logRef.current?.console?.scrollToLine(highlight.line);
}
}, [searchIndex, searchQuery, search.loading, logRef.current?.console]);

return (
<>
<LogOutputWrapper>
{/* eslint-disable-next-line react/no-array-index-key */}
{useTestsSlot('logOutputTop').map((element, i) => createElement(Fragment, {key: i}, element))}
<LogOutputPure ref={logRef} isRunning={isRunning} {...options} />
</LogOutputWrapper>
{isFullscreen ? createPortal(<FullscreenLogOutput {...options} />, fullscreenContainer) : null}
</>
);
};

export default memo(LogOutputV1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import styled from 'styled-components';

import Colors from '@src/styles/Colors';

export const SourceList = styled.ul<{$open?: boolean; $root?: boolean}>`
display: flex;
flex-direction: column;
list-style: none;
padding: 0;
margin: 0;
${({$open}) => ($open ? 'flex: 1;' : '')}
${({$root}) => ($root ? 'height: 100%;' : '')}
`;

export const SourceSection = styled.li<{$open?: boolean}>`
border: 1px solid ${Colors.slate700};
border-radius: 3px;
display: flex;
flex-direction: column;
margin-bottom: 8px;
${({$open}) =>
$open
? 'flex: 1;'
: `
${SourceContent} {
display: none;
}
`}
`;

export const SourceContent = styled.div<{$empty?: boolean}>`
position: relative;
display: flex;
align-items: stretch;
margin: 0;
background: ${Colors.slate900};
min-height: 300px;
flex: 1;
${({$empty}) => ($empty ? 'min-height: 80px;' : '')}
`;

export const SourceHeader = styled.header`
display: flex;
align-items: center;
background: ${Colors.slate900};
padding: 10px 16px;
gap: 16px;
user-select: none;
cursor: pointer;
width: 100%;
`;

export const Container = styled.div`
display: flex;
flex-direction: column;
`;
82 changes: 82 additions & 0 deletions packages/web/src/components/molecules/LogOutput/LogOutputV2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, {memo, useMemo, useRef} from 'react';

import {uniq} from 'lodash';

import {LogOutputWrapper} from './LogOutput.styled';
import LogOutputPure, {LogOutputPureRef} from './LogOutputPure';
import * as S from './LogOutputV2.styled';
import {LogOutputProps} from './useLogOutput';
import {useLogsV2} from './useLogsV2';

const UNKNOWN_SOURCE = 'system' as const;

const LogOutputV2: React.FC<LogOutputProps> = props => {
const {isRunning, wrap, LineComponent, executionId} = props;

const [openSource, _setOpenSource] = React.useState<string>('');
const setOpenSource = (source: string) => {
_setOpenSource(prev => (prev === source ? '' : source));
};

const logRef = useRef<LogOutputPureRef>(null);

const logs = useLogsV2(executionId, isRunning);

const logSources = useMemo(
() => [
UNKNOWN_SOURCE,
...uniq(logs.map(log => log.source).filter((source): source is string => Boolean(source && source.length > 0))),
],
[logs]
);
const logsBySource = useMemo(() => {
const dict: Record<string, string> = {};
logs.forEach(log => {
let source = log.source && log.source.trim().length ? log.source : UNKNOWN_SOURCE;
if (!dict[source]) {
dict[source] = '';
}
let previous = dict[source];
if (previous.length && !previous.endsWith('\n')) {
previous += '\n';
}
dict[source] = previous + log.content;
});
// set 'No logs' for sources without logs
logSources.forEach(source => {
if (!dict[source]) {
dict[source] = 'No logs';
}
});

if (dict[UNKNOWN_SOURCE].trim() === '') {
delete dict[UNKNOWN_SOURCE];
}

return dict;
}, [logs, logSources]);

return (
<S.Container>
{logSources.map(source => (
<S.SourceSection $open={openSource === source}>
<S.SourceHeader onClick={() => setOpenSource(source)}>{source}</S.SourceHeader>
<S.SourceContent>
<LogOutputWrapper>
<LogOutputPure
ref={logRef}
logs={logsBySource[source]}
isRunning={isRunning}
hideActions
wrap={wrap}
LineComponent={LineComponent}
/>
</LogOutputWrapper>
</S.SourceContent>
</S.SourceSection>
))}
</S.Container>
);
};

export default memo(LogOutputV2);
36 changes: 36 additions & 0 deletions packages/web/src/components/molecules/LogOutput/useLogsV2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {useEffect, useState} from 'react';
import {useAsync} from 'react-use';
import useWebSocket from 'react-use-websocket';

import {useWsEndpoint} from '@services/apiEndpoint';

import {getRtkIdToken} from '@utils/rtk';

export type LogLine = {content: string; source?: string};

export const useLogsV2 = (executionId?: string, enabled?: boolean) => {
const wsRoot = useWsEndpoint();
const [logs, setLogs] = useState<LogLine[]>([]);

// TODO: Consider getting token different way than using the one from RTK
const {value: token, loading: tokenLoading} = useAsync(getRtkIdToken);
useWebSocket(
`${wsRoot}/executions/${executionId}/logs/stream/v2`,
{
onMessage: e => {
const logData = JSON.parse(e.data);
setLogs(prev => [...prev, logData]);
},
shouldReconnect: () => true,
retryOnError: true,
queryParams: token ? {token} : {},
},
!tokenLoading
);

useEffect(() => {
setLogs([]);
}, [executionId]);

return logs;
};
3 changes: 3 additions & 0 deletions packages/web/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {GlobalStyle} from '@styles/globalStyles';

import AppRoot from './AppRoot';
import './antd-theme/antd-customized.css';
import {initializeApiInfoData} from './utils/apiInfo';

initializeApiInfoData();

(async () => {
const container = document.getElementById('root');
Expand Down
24 changes: 24 additions & 0 deletions packages/web/src/utils/apiInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {getApiEndpoint} from '@services/apiEndpoint';

let apiInfoData: any = {};

let features: {logsV2: boolean} = {logsV2: false};

export function getApiInfoData() {
return apiInfoData;
}

export async function initializeApiInfoData() {
// TODO: what to do if this request fails?
const response = await fetch(`${getApiEndpoint()}/info`);
const data = await response.json();
apiInfoData = data ?? {};

if ('features' in apiInfoData && typeof apiInfoData.features === 'object') {
features = apiInfoData.features;
}
}

export function isFeatureEnabled(feature: keyof typeof features) {
return features[feature];
}

0 comments on commit fee7969

Please sign in to comment.