Skip to content

Commit 1f4b899

Browse files
committed
perf: optimize log components with virtualization and improved UX
- Add virtual scrolling to handle large log volumes efficiently - Implement memory limits (10K for main logs, 5K for per-app logs) - Add floating "scroll to bottom" button when auto-scroll is off - Fix auto-scroll to reach absolute bottom of multi-line entries - Memoize expensive operations and event handlers - Add debounced scroll detection for better performance
1 parent ecd64a5 commit 1f4b899

File tree

5 files changed

+366
-98
lines changed

5 files changed

+366
-98
lines changed

src-frontend/bun.lockb

831 Bytes
Binary file not shown.

src-frontend/components/apps/app-logs.tsx

Lines changed: 168 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import 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";
34
import { Button } from "@/components/ui/button";
45
import { ScrollArea } from "@/components/ui/scroll-area";
56
import { getLogs, type LogsResponse } from "@/lib/api/logs";
67
import { Badge } from "@/components/ui/badge";
8+
import { useVirtualizer } from "@tanstack/react-virtual";
9+
10+
const MAX_LOGS = 5000; // Limit logs for app view
711

812
export 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}>&nbsp;</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

Comments
 (0)