diff --git a/CHANGELOG.md b/CHANGELOG.md index a217ee4a..16f0004d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/src/index.ts b/backend/src/index.ts index af5ca4db..cdd69b67 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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' }); } diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 3ceafc2b..406987c6 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -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() {