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

feat: logs v2 #1027

Merged
merged 3 commits into from
Mar 29, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
49 changes: 49 additions & 0 deletions packages/web/src/components/molecules/LogOutput/useLogsV2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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, isRunning?: 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);
let content = logData.content || '';
try {
const formattedTime = new Intl.DateTimeFormat('default', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false, // Use 24-hour format
}).format(new Date(logData.time));
content = `[${formattedTime}] ${content}`;
} catch {
// Ignore
}
logData.content = content;
setLogs(prev => [...prev, logData]);
},
shouldReconnect: () => Boolean(isRunning),
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];
}
Loading