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
+1
View File
@@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **Fixed:** Browser Out of Memory crash in `GlobalObservabilityView` (Logs view) — the component rendered all log entries (up to 1,859+) as real DOM nodes with no virtualization, causing ~9,600 DOM nodes and ~25 MB of GC pressure every 5-second poll cycle. On RAM-constrained hosts (host system was at 97% usage) the browser renderer process OOMed within minutes. Fixed by capping DOM rendering to the last 300 entries (`MAX_DISPLAY_ROWS`), reducing the in-memory SSE log cap from 10,000 to 2,000 entries (`MAX_LOG_ENTRIES`), switching auto-scroll from `behavior: 'smooth'` (stacked layout animations) to `behavior: 'instant'`, and reducing the backend polling response limit from 2,000 to 500 lines.
- **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes — the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal.
- **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes — the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node.
- **Fixed:** `streamStats` Docker stats stream leaking after WebSocket client disconnect — the Docker daemon stream was never destroyed when the WS closed, causing orphaned streams to poll the daemon indefinitely. Fixed by adding a `ws.on('close')` handler that calls `stats.destroy()`. Also guards all `ws.send()` calls with a `readyState === OPEN` check to prevent silent errors on a closed socket.
+4 -2
View File
@@ -1072,9 +1072,11 @@ app.get('/api/logs/global', async (req: Request, res: Response) => {
}
}));
// Sort globally by timestamp ascending (newest bottom) and limit to 2000 lines
// Sort globally by timestamp ascending (newest bottom).
// Limit to 500 lines — the client renders at most 300 rows at once, so
// sending 2000 lines was wasting bandwidth and inflating JSON parse time.
allLogs.sort((a, b) => a.timestampMs - b.timestampMs);
res.json(allLogs.slice(-2000));
res.json(allLogs.slice(-500));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch global logs' });
}
@@ -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>