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:
SaelixCode
2026-03-20 11:59:24 -04:00
parent 18314119b0
commit 0db6c946e7
3 changed files with 22 additions and 6 deletions
@@ -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>