1- import React , { useEffect , useRef , useState } from 'react' ;
1+ import React , { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
22import { useTranslation } from 'react-i18next' ;
33import classNames from 'classnames' ;
4- import { Mode } from '@cloudscape-design/global-styles' ;
5- import { FitAddon } from '@xterm/addon-fit' ;
6- import { Terminal } from '@xterm/xterm' ;
74
8- import { Container , Header , ListEmptyMessage , Loader , TextContent } from 'components' ;
5+ import { Box , Button , Code , Container , Header , ListEmptyMessage , Loader , TextContent } from 'components' ;
96
10- import { useAppSelector } from 'hooks' ;
7+ import { useLocalStorageState } from 'hooks/useLocalStorageState ' ;
118import { useLazyGetProjectLogsQuery } from 'services/project' ;
129
13- import { selectSystemMode } from 'App/slice ' ;
10+ import { decodeLogs } from './helpers ' ;
1411
1512import { IProps } from './types' ;
1613
1714import styles from './styles.module.scss' ;
1815
19- const LIMIT_LOG_ROWS = 1000 ;
16+ const LIMIT_LOG_ROWS = 100 ;
17+ const LOADING_SCROLL_GAP = 300 ;
2018
2119export const Logs : React . FC < IProps > = ( { className, projectName, runName, jobSubmissionId } ) => {
2220 const { t } = useTranslation ( ) ;
23- const appliedTheme = useAppSelector ( selectSystemMode ) ;
21+ const codeRef = useRef < HTMLDivElement > ( null ) ;
22+ const nextTokenRef = useRef < string | undefined > ( undefined ) ;
23+ const scrollPositionByBottom = useRef < number > ( 0 ) ;
2424
25- const terminalInstance = useRef < Terminal > ( new Terminal ( { scrollback : 10000000 } ) ) ;
26- const fitAddonInstance = useRef < FitAddon > ( new FitAddon ( ) ) ;
2725 const [ logsData , setLogsData ] = useState < ILogItem [ ] > ( [ ] ) ;
2826 const [ isLoading , setIsLoading ] = useState ( false ) ;
29-
3027 const [ getProjectLogs ] = useLazyGetProjectLogsQuery ( ) ;
28+ const [ isEnabledDecoding , setIsEnabledDecoding ] = useLocalStorageState ( 'enable-encode-logs' , false ) ;
29+ // const [isShowTimestamp, setIsShowTimestamp] = useLocalStorageState('enable-showing-timestamp-logs', false);
30+
31+ const logsForView = useMemo ( ( ) => {
32+ if ( isEnabledDecoding ) {
33+ return decodeLogs ( logsData ) ;
34+ }
35+
36+ return logsData ;
37+ } , [ logsData , isEnabledDecoding ] ) ;
38+
39+ const saveScrollPositionByBottom = ( ) => {
40+ if ( ! codeRef . current ) return ;
3141
32- const writeDataToTerminal = ( logs : ILogItem [ ] ) => {
33- logs . forEach ( ( logItem ) => {
34- terminalInstance . current . write ( logItem . message . replace ( / (?< ! \r ) \n / g, '\r\n' ) ) ;
35- } ) ;
42+ const { clientHeight, scrollHeight, scrollTop } = codeRef . current ;
43+ scrollPositionByBottom . current = scrollHeight - clientHeight - scrollTop ;
44+ } ;
45+
46+ const restoreScrollPositionByBottom = ( ) => {
47+ if ( ! codeRef . current ) return ;
3648
37- fitAddonInstance . current . fit ( ) ;
49+ const { clientHeight, scrollHeight } = codeRef . current ;
50+ codeRef . current . scrollTo ( 0 , scrollHeight - clientHeight - scrollPositionByBottom . current ) ;
3851 } ;
3952
40- const getNextLogItems = ( nextToken ?: string ) => {
53+ const checkNeedMoreLoadingData = ( ) => {
54+ if ( ! codeRef . current ) return ;
55+
56+ const { clientHeight, scrollHeight } = codeRef . current ;
57+
58+ if ( scrollHeight - clientHeight <= LOADING_SCROLL_GAP ) {
59+ getLogItems ( ) ;
60+ }
61+ } ;
62+
63+ const getLogItems = ( nextToken ?: string ) => {
4164 setIsLoading ( true ) ;
4265
4366 if ( ! jobSubmissionId ) {
@@ -47,86 +70,131 @@ export const Logs: React.FC<IProps> = ({ className, projectName, runName, jobSub
4770 getProjectLogs ( {
4871 project_name : projectName ,
4972 run_name : runName ,
50- descending : false ,
51- job_submission_id : jobSubmissionId ?? '' ,
73+ descending : true ,
74+ job_submission_id : jobSubmissionId ,
5275 next_token : nextToken ,
5376 limit : LIMIT_LOG_ROWS ,
5477 } )
5578 . unwrap ( )
5679 . then ( ( response ) => {
57- setLogsData ( ( old ) => [ ...old , ...response . logs ] ) ;
58-
59- writeDataToTerminal ( response . logs ) ;
60-
61- if ( response . next_token ) {
62- getNextLogItems ( response . next_token ) ;
63- } else {
64- setIsLoading ( false ) ;
65- }
80+ saveScrollPositionByBottom ( ) ;
81+ const reversed = response . logs . toReversed ( ) ;
82+ setLogsData ( ( old ) => [ ...reversed , ...old ] ) ;
83+ nextTokenRef . current = response . next_token ;
84+ setIsLoading ( false ) ;
6685 } )
6786 . catch ( ( ) => setIsLoading ( false ) ) ;
6887 } ;
6988
89+ const getNextLogItems = ( ) => {
90+ if ( nextTokenRef . current ) {
91+ getLogItems ( nextTokenRef . current ) ;
92+ }
93+ } ;
94+
95+ const toggleDecodeLogs = ( ) => {
96+ saveScrollPositionByBottom ( ) ;
97+ setIsEnabledDecoding ( ! isEnabledDecoding ) ;
98+ } ;
99+
70100 useEffect ( ( ) => {
71- if ( appliedTheme === Mode . Light ) {
72- terminalInstance . current . options . theme = {
73- foreground : '#000716' ,
74- background : '#ffffff' ,
75- selectionBackground : '#B4D5FE' ,
76- } ;
101+ getLogItems ( ) ;
102+ } , [ ] ) ;
103+
104+ useLayoutEffect ( ( ) => {
105+ if ( logsForView . length && logsForView . length <= LIMIT_LOG_ROWS ) {
106+ scrollToBottom ( ) ;
77107 } else {
78- terminalInstance . current . options . theme = {
79- foreground : '#b6bec9' ,
80- background : '#161d26' ,
81- } ;
108+ restoreScrollPositionByBottom ( ) ;
82109 }
83- } , [ appliedTheme ] ) ;
84110
85- useEffect ( ( ) => {
86- terminalInstance . current . loadAddon ( fitAddonInstance . current ) ;
111+ if ( logsForView . length ) checkNeedMoreLoadingData ( ) ;
112+ } , [ logsForView ] ) ;
87113
88- getNextLogItems ( ) ;
114+ const onScroll = useCallback < EventListener > (
115+ ( event ) => {
116+ const element = event . target as HTMLDivElement ;
89117
90- const onResize = ( ) => {
91- fitAddonInstance . current . fit ( ) ;
92- } ;
118+ if ( element . scrollTop <= LOADING_SCROLL_GAP && ! isLoading ) {
119+ getNextLogItems ( ) ;
120+ }
121+ } ,
122+ [ isLoading , logsForView ] ,
123+ ) ;
124+
125+ useEffect ( ( ) => {
126+ if ( ! codeRef . current ) return ;
93127
94- window . addEventListener ( 'resize ' , onResize ) ;
128+ codeRef . current . addEventListener ( 'scroll ' , onScroll ) ;
95129
96130 return ( ) => {
97- window . removeEventListener ( 'resize ' , onResize ) ;
131+ if ( codeRef . current ) codeRef . current . removeEventListener ( 'scroll ' , onScroll ) ;
98132 } ;
99- } , [ ] ) ;
133+ } , [ codeRef . current , onScroll ] ) ;
100134
101- useEffect ( ( ) => {
102- const element = document . getElementById ( 'terminal' ) ;
135+ const scrollToBottom = ( ) => {
136+ if ( ! codeRef . current ) return ;
103137
104- if ( terminalInstance . current && element ) {
105- terminalInstance . current . open ( element ) ;
106- }
107- } , [ ] ) ;
138+ const { clientHeight, scrollHeight } = codeRef . current ;
139+ codeRef . current . scrollTo ( 0 , scrollHeight - clientHeight ) ;
140+ } ;
108141
109142 return (
110143 < div className = { classNames ( styles . logs , className ) } >
111144 < Container
112145 header = {
113- < Header variant = "h2" >
114- < div className = { styles . headerContainer } >
115- { t ( 'projects.run.log' ) }
116- < Loader show = { isLoading } padding = { 'n' } className = { classNames ( styles . loader ) } loadingText = { '' } />
146+ < div className = { styles . headerContainer } >
147+ < div className = { styles . headerTitle } >
148+ < Header variant = "h2" > { t ( 'projects.run.log' ) } </ Header >
117149 </ div >
118- </ Header >
150+
151+ < Loader
152+ show = { isLoading && Boolean ( logsForView . length ) }
153+ padding = { 'n' }
154+ className = { styles . loader }
155+ loadingText = { '' }
156+ />
157+
158+ < div className = { styles . switchers } >
159+ < Box >
160+ < Button
161+ ariaLabel = "Legacy mode"
162+ formAction = "none"
163+ iconName = "gen-ai"
164+ variant = { isEnabledDecoding ? 'primary' : 'icon' }
165+ onClick = { toggleDecodeLogs }
166+ />
167+ </ Box >
168+
169+ { /*<Box>*/ }
170+ { /* <Toggle onChange={({ detail }) => setIsShowTimestamp(detail.checked)} checked={isShowTimestamp}>*/ }
171+ { /* Show timestamp*/ }
172+ { /* </Toggle>*/ }
173+ { /*</Box>*/ }
174+ </ div >
175+ </ div >
119176 }
120177 >
121178 < TextContent >
122- { ! isLoading && ! logsData . length && (
179+ { ! isLoading && ! logsForView . length && (
123180 < ListEmptyMessage
124181 title = { t ( 'projects.run.log_empty_message_title' ) }
125182 message = { t ( 'projects.run.log_empty_message_text' ) }
126183 />
127184 ) }
128185
129- < div className = { styles . terminal } id = "terminal" />
186+ { ! logsForView . length && < Loader show = { isLoading } className = { styles . mainLoader } /> }
187+
188+ { Boolean ( logsForView . length ) && (
189+ < Code className = { styles . terminal } ref = { codeRef } >
190+ { logsForView . map ( ( log , i ) => (
191+ < p key = { i } >
192+ { /*{isShowTimestamp && <span className={styles.timestamp}>{log.timestamp}</span> }*/ }
193+ { log . message }
194+ </ p >
195+ ) ) }
196+ </ Code >
197+ ) }
130198 </ TextContent >
131199 </ Container >
132200 </ div >
0 commit comments