diff --git a/packages/web/src/components/molecules/LogOutput/LogOutput.tsx b/packages/web/src/components/molecules/LogOutput/LogOutput.tsx index 356782367..d85e5f4b4 100644 --- a/packages/web/src/components/molecules/LogOutput/LogOutput.tsx +++ b/packages/web/src/components/molecules/LogOutput/LogOutput.tsx @@ -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 = props => { - const {isRunning} = props; - - const logRef = useRef(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 ( - <> - - {/* eslint-disable-next-line react/no-array-index-key */} - {useTestsSlot('logOutputTop').map((element, i) => createElement(Fragment, {key: i}, element))} - - - {isFullscreen ? createPortal(, fullscreenContainer) : null} - - ); + const isV2 = isFeatureEnabled('logsV2'); + return <>{isV2 ? : }; }; export default memo(LogOutput); diff --git a/packages/web/src/components/molecules/LogOutput/LogOutputActions.tsx b/packages/web/src/components/molecules/LogOutput/LogOutputActions.tsx index 3da46fc8c..16b6a9425 100644 --- a/packages/web/src/components/molecules/LogOutput/LogOutputActions.tsx +++ b/packages/web/src/components/molecules/LogOutput/LogOutputActions.tsx @@ -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'; @@ -21,15 +23,18 @@ const LogOutputActions: React.FC = props => { const isSecureContext = useSecureContext(); const filename = useLocation().lastPathSegment; + const isV2 = isFeatureEnabled('logsV2'); + return ( - + {isV2 ? null : } {isSecureContext ? ( ) : ( )} - + + {isV2 ? null : } ); }; diff --git a/packages/web/src/components/molecules/LogOutput/LogOutputV1.tsx b/packages/web/src/components/molecules/LogOutput/LogOutputV1.tsx new file mode 100644 index 000000000..deffd9d7a --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/LogOutputV1.tsx @@ -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(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 ( + <> + + {/* eslint-disable-next-line react/no-array-index-key */} + {useTestsSlot('logOutputTop').map((element, i) => createElement(Fragment, {key: i}, element))} + + + {isFullscreen ? createPortal(, fullscreenContainer) : null} + + ); +}; + +export default memo(LogOutputV1); diff --git a/packages/web/src/components/molecules/LogOutput/LogOutputV2.styled.tsx b/packages/web/src/components/molecules/LogOutput/LogOutputV2.styled.tsx new file mode 100644 index 000000000..fc86ddac6 --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/LogOutputV2.styled.tsx @@ -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; +`; diff --git a/packages/web/src/components/molecules/LogOutput/LogOutputV2.tsx b/packages/web/src/components/molecules/LogOutput/LogOutputV2.tsx new file mode 100644 index 000000000..389ac081b --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/LogOutputV2.tsx @@ -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 = props => { + const {isRunning, wrap, LineComponent, executionId} = props; + + const [openSource, _setOpenSource] = React.useState(''); + const setOpenSource = (source: string) => { + _setOpenSource(prev => (prev === source ? '' : source)); + }; + + const logRef = useRef(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 = {}; + 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 ( + + {logSources.map(source => ( + + setOpenSource(source)}>{source} + + + + + + + ))} + + ); +}; + +export default memo(LogOutputV2); diff --git a/packages/web/src/components/molecules/LogOutput/useLogsV2.ts b/packages/web/src/components/molecules/LogOutput/useLogsV2.ts new file mode 100644 index 000000000..21e176a72 --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/useLogsV2.ts @@ -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([]); + + // 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; +}; diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index 4de6666cc..0101abfe3 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -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'); diff --git a/packages/web/src/utils/apiInfo.ts b/packages/web/src/utils/apiInfo.ts new file mode 100644 index 000000000..c6686f15b --- /dev/null +++ b/packages/web/src/utils/apiInfo.ts @@ -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]; +}