mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
fix(logs): cap DOM rendering to 300 rows to prevent OOM crash
Playwright investigation revealed the Logs view (GlobalObservabilityView) rendered 1,859+ log entries as real DOM nodes (9,616 total DOM nodes) with no virtualization. Combined with a 5-second polling cycle replacing all React elements each time and smooth-scroll animations stacking on every update, the renderer process grew rapidly on a host running at 97% RAM usage, crashing the browser tab with Out of Memory within minutes. Fixes: - Cap rendered DOM rows to MAX_DISPLAY_ROWS (300) via .slice(-300) so the browser only ever holds ~1,500 log-related DOM nodes regardless of how many entries are in state - Add a truncation notice when log count exceeds the display cap - Reduce SSE-mode in-memory log cap from 10,000 to MAX_LOG_ENTRIES (2,000) - Switch auto-scroll from behavior:'smooth' to behavior:'instant' to stop stacking layout animations on every 5-second poll - Reduce /api/logs/global response limit from 2,000 to 500 lines since the client renders at most 300 rows, making the extra payload wasteful
This commit is contained in:
@@ -7,6 +7,12 @@ import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
// Max entries held in React state. Bounds SSE-mode memory growth.
|
||||
const MAX_LOG_ENTRIES = 2000;
|
||||
// Max rows rendered as DOM nodes at once. Prevents the renderer from
|
||||
// creating thousands of DOM nodes that OOM the browser on RAM-constrained hosts.
|
||||
const MAX_DISPLAY_ROWS = 300;
|
||||
|
||||
|
||||
interface LogEntry {
|
||||
stackName: string;
|
||||
@@ -96,7 +102,7 @@ export function GlobalObservabilityView() {
|
||||
setLogs(prev => {
|
||||
const merged = [...prev, ...batch];
|
||||
merged.sort((a, b) => a.timestampMs - b.timestampMs);
|
||||
return merged.slice(-10000);
|
||||
return merged.slice(-MAX_LOG_ENTRIES);
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
@@ -156,8 +162,10 @@ export function GlobalObservabilityView() {
|
||||
}, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAutoScrollEnabled) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
if (isAutoScrollEnabled && bottomRef.current) {
|
||||
// Use instant scroll to avoid stacking smooth-scroll animations on every
|
||||
// 5-second poll cycle, which wastes layout work and renderer memory.
|
||||
bottomRef.current.scrollIntoView({ behavior: 'instant' });
|
||||
}
|
||||
}, [filteredLogs, isAutoScrollEnabled]);
|
||||
|
||||
@@ -258,7 +266,12 @@ export function GlobalObservabilityView() {
|
||||
<div className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent" onScroll={handleScroll}>
|
||||
{filteredLogs.length > 0 ? (
|
||||
<>
|
||||
{filteredLogs.map((log, idx) => (
|
||||
{filteredLogs.length > MAX_DISPLAY_ROWS && (
|
||||
<div className="text-gray-600 italic text-xs text-center mb-3 py-1 border-b border-gray-800">
|
||||
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
|
||||
</div>
|
||||
)}
|
||||
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log, idx) => (
|
||||
<div key={idx} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
||||
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
||||
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
|
||||
|
||||
Reference in New Issue
Block a user