11import Ansi from "ansi-to-react" ;
2- import { useEffect , useState , useRef } from "react" ;
2+ import { useEffect , useState , useRef , useCallback } from "react" ;
3+ import { ArrowDown } from "lucide-react" ;
34import { Button } from "@/components/ui/button" ;
45import { ScrollArea } from "@/components/ui/scroll-area" ;
56import { getLogs , type LogsResponse } from "@/lib/api/logs" ;
67import { Badge } from "@/components/ui/badge" ;
8+ import { useVirtualizer } from "@tanstack/react-virtual" ;
9+
10+ const MAX_LOGS = 5000 ; // Limit logs for app view
711
812export function AppLogs ( { appId } : { appId : string } ) {
913 const [ logs , setLogs ] = useState < LogsResponse [ "logs" ] > ( [ ] ) ;
1014 const [ nextToken , setNextToken ] = useState < number > ( 1 ) ;
11- const [ isAutoScroll , setIsAutoScroll ] = useState ( true ) ;
15+ const [ isAutoScroll , setIsAutoScroll ] = useState ( false ) ;
1216 const scrollAreaRef = useRef < HTMLDivElement > ( null ) ;
1317 const logsRef = useRef ( logs ) ;
1418 const nextTokenRef = useRef ( nextToken ) ;
@@ -33,6 +37,12 @@ export function AppLogs({ appId }: { appId: string }) {
3337 while ( hasMore ) {
3438 const response = await getLogs ( appId , localNextToken , 1000 ) ;
3539 accumulatedLogs = [ ...accumulatedLogs , ...response . logs ] ;
40+
41+ // Limit logs to prevent memory issues
42+ if ( accumulatedLogs . length > MAX_LOGS ) {
43+ accumulatedLogs = accumulatedLogs . slice ( - MAX_LOGS ) ;
44+ }
45+
3646 localNextToken = response . nextToken ;
3747 hasMore = response . hasMore ;
3848 setLogs ( [ ...accumulatedLogs ] ) ;
@@ -53,17 +63,43 @@ export function AppLogs({ appId }: { appId: string }) {
5363 return ( ) => clearInterval ( interval ) ;
5464 } , [ appId ] ) ;
5565
66+ // Virtualizer for efficient rendering - add 1 for dummy item
67+ const virtualizer = useVirtualizer ( {
68+ count : logs . length + 1 , // +1 for dummy end marker
69+ getScrollElement : ( ) => {
70+ const scrollContainer = scrollAreaRef . current ?. querySelector (
71+ "[data-radix-scroll-area-viewport]" ,
72+ ) ;
73+ return scrollContainer as HTMLElement ;
74+ } ,
75+ estimateSize : ( index ) => {
76+ // Dummy item at the end has minimal height
77+ return index === logs . length ? 1 : 30 ;
78+ } ,
79+ overscan : 10 ,
80+ } ) ;
81+
5682 // Auto-scroll effect
5783 useEffect ( ( ) => {
58- if ( isAutoScroll && scrollAreaRef . current ) {
59- const scrollContainer = scrollAreaRef . current . querySelector (
84+ if ( isAutoScroll && logs . length > 0 ) {
85+ const scrollContainer = scrollAreaRef . current ? .querySelector (
6086 "[data-radix-scroll-area-viewport]" ,
6187 ) ;
6288 if ( scrollContainer ) {
63- scrollContainer . scrollTop = scrollContainer . scrollHeight ;
89+ // Use requestAnimationFrame to ensure DOM has updated
90+ requestAnimationFrame ( ( ) => {
91+ // Get the actual content height from the virtual container
92+ const virtualContent = scrollContainer . querySelector (
93+ '[style*="height"]' ,
94+ ) as HTMLElement ;
95+ if ( virtualContent ) {
96+ const contentHeight = parseInt ( virtualContent . style . height ) ;
97+ scrollContainer . scrollTop = contentHeight ;
98+ }
99+ } ) ;
64100 }
65101 }
66- } , [ logs , isAutoScroll ] ) ;
102+ } , [ logs . length , isAutoScroll ] ) ;
67103
68104 // set isAutoScroll to false when user scrolls up, and true when user scrolls to the bottom
69105 useEffect ( ( ) => {
@@ -72,23 +108,45 @@ export function AppLogs({ appId }: { appId: string }) {
72108 ) ;
73109 if ( ! scrollContainer ) return ;
74110
111+ let scrollTimeout : NodeJS . Timeout ;
112+
75113 const handleScroll = ( ) => {
76- const isAtBottom =
77- scrollContainer . scrollHeight - scrollContainer . scrollTop ===
78- scrollContainer . clientHeight ;
79- setIsAutoScroll ( isAtBottom ) ;
114+ // Debounce scroll events
115+ clearTimeout ( scrollTimeout ) ;
116+
117+ scrollTimeout = setTimeout ( ( ) => {
118+ // For virtualized content, we need to check against the virtual total size
119+ const virtualHeight = virtualizer . getTotalSize ( ) ;
120+ const scrollTop = scrollContainer . scrollTop ;
121+ const clientHeight = scrollContainer . clientHeight ;
122+ const threshold = 100 ; // Increased threshold for virtual scrolling
123+
124+ const distanceFromBottom = virtualHeight - scrollTop - clientHeight ;
125+ const isAtBottom = distanceFromBottom < threshold ;
126+
127+ // Only update if the value actually changes
128+ setIsAutoScroll ( ( prev ) => {
129+ if ( prev !== isAtBottom ) {
130+ return isAtBottom ;
131+ }
132+ return prev ;
133+ } ) ;
134+ } , 100 ) ;
80135 } ;
81136
82137 scrollContainer . addEventListener ( "scroll" , handleScroll ) ;
83- return ( ) => scrollContainer . removeEventListener ( "scroll" , handleScroll ) ;
84- } , [ ] ) ;
138+ return ( ) => {
139+ scrollContainer . removeEventListener ( "scroll" , handleScroll ) ;
140+ clearTimeout ( scrollTimeout ) ;
141+ } ;
142+ } , [ virtualizer ] ) ;
85143
86- const handleClear = ( ) => {
144+ const handleClear = useCallback ( ( ) => {
87145 setLogs ( [ ] ) ;
88146 setNextToken ( 1 ) ;
89- } ;
147+ } , [ ] ) ;
90148
91- const getLevelColor = ( level : string ) => {
149+ const getLevelColor = useCallback ( ( level : string ) => {
92150 switch ( level ) {
93151 case "debug" :
94152 return "bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-100 hover:text-gray-800 hover:border-gray-200" ;
@@ -102,7 +160,9 @@ export function AppLogs({ appId }: { appId: string }) {
102160 default :
103161 return "bg-gray-100 text-gray-800 border-gray-200" ;
104162 }
105- } ;
163+ } , [ ] ) ;
164+
165+ const virtualItems = virtualizer . getVirtualItems ( ) ;
106166
107167 return (
108168 < div className = "flex h-full flex-col gap-2" >
@@ -113,33 +173,105 @@ export function AppLogs({ appId }: { appId: string }) {
113173 </ Button >
114174 </ div >
115175 < ScrollArea
116- className = "bg-background flex-1 rounded-lg border p-4 font-mono text-sm"
176+ className = "bg-background relative flex-1 rounded-lg border p-4 font-mono text-sm"
117177 ref = { scrollAreaRef }
118178 >
119179 { logs . length === 0 ? (
120180 < div className = "text-muted-foreground flex h-40 items-center justify-center" >
121181 No logs to display
122182 </ div >
123183 ) : (
124- < table className = "table-auto border-separate border-spacing-1 text-start" >
125- < tbody >
126- { logs . map ( ( log ) => (
127- < tr key = { `${ appId } -${ log . lineNumber } ` } >
128- < td className = "text-muted-foreground align-baseline text-nowrap" >
129- { new Date ( log . timestamp ) . toLocaleTimeString ( ) }
130- </ td >
131- < td className = "w-16 text-end align-baseline" >
132- < Badge className = { getLevelColor ( log . level ) } >
133- { log . level . toUpperCase ( ) }
134- </ Badge >
135- </ td >
136- < td className = "align-baseline text-wrap" >
137- < Ansi > { log . message } </ Ansi >
138- </ td >
139- </ tr >
140- ) ) }
141- </ tbody >
142- </ table >
184+ < div
185+ style = { {
186+ height : `${ virtualizer . getTotalSize ( ) } px` ,
187+ width : "100%" ,
188+ position : "relative" ,
189+ } }
190+ >
191+ < div
192+ style = { {
193+ position : "absolute" ,
194+ top : 0 ,
195+ left : 0 ,
196+ width : "100%" ,
197+ transform : `translateY(${ virtualItems [ 0 ] ?. start ?? 0 } px)` ,
198+ } }
199+ >
200+ < table className = "table-auto border-separate border-spacing-1 text-start" >
201+ < tbody >
202+ { virtualItems . map ( ( virtualItem ) => {
203+ // Check if this is the dummy item
204+ if ( virtualItem . index === logs . length ) {
205+ return (
206+ < tr
207+ key = "dummy-end-marker"
208+ data-index = { virtualItem . index }
209+ ref = { virtualizer . measureElement }
210+ style = { {
211+ height : `${ virtualItem . size } px` ,
212+ } }
213+ >
214+ < td colSpan = { 3 } > </ td >
215+ </ tr >
216+ ) ;
217+ }
218+
219+ const log = logs [ virtualItem . index ] ;
220+ return (
221+ < tr
222+ key = { `${ appId } -${ log . lineNumber } ` }
223+ data-index = { virtualItem . index }
224+ ref = { virtualizer . measureElement }
225+ style = { {
226+ height : `${ virtualItem . size } px` ,
227+ } }
228+ >
229+ < td className = "text-muted-foreground align-baseline text-nowrap" >
230+ { new Date ( log . timestamp ) . toLocaleTimeString ( ) }
231+ </ td >
232+ < td className = "w-16 text-end align-baseline" >
233+ < Badge className = { getLevelColor ( log . level ) } >
234+ { log . level . toUpperCase ( ) }
235+ </ Badge >
236+ </ td >
237+ < td className = "align-baseline text-wrap" >
238+ < Ansi > { log . message } </ Ansi >
239+ </ td >
240+ </ tr >
241+ ) ;
242+ } ) }
243+ </ tbody >
244+ </ table >
245+ </ div >
246+ </ div >
247+ ) }
248+
249+ { /* Floating scroll to bottom button */ }
250+ { ! isAutoScroll && logs . length > 0 && (
251+ < Button
252+ className = "absolute right-4 bottom-4 z-10 shadow-lg"
253+ size = "icon"
254+ onClick = { ( ) => {
255+ const scrollContainer = scrollAreaRef . current ?. querySelector (
256+ "[data-radix-scroll-area-viewport]" ,
257+ ) ;
258+ if ( scrollContainer ) {
259+ // Get the actual content height from the virtual container
260+ const virtualContent = scrollContainer . querySelector (
261+ '[style*="height"]' ,
262+ ) as HTMLElement ;
263+ if ( virtualContent ) {
264+ const contentHeight = parseInt ( virtualContent . style . height ) ;
265+ scrollContainer . scrollTop = contentHeight ;
266+ }
267+ // Force auto-scroll back on when clicking the button
268+ setIsAutoScroll ( true ) ;
269+ }
270+ } }
271+ title = "Scroll to bottom"
272+ >
273+ < ArrowDown className = "h-4 w-4" />
274+ </ Button >
143275 ) }
144276 </ ScrollArea >
145277 </ div >
0 commit comments