diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f0004d..6992a7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +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:** 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. Also replaced `key={idx}` (array index) with a monotonic `_id` counter stamped at ingestion time so the slice window shifting no longer forces O(n) DOM mutations per new log line — React now only reconciles the one entry that actually changed. - **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/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 406987c6..13f7fdc4 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -21,6 +21,9 @@ interface LogEntry { level: string; message: string; timestampMs: number; + // Assigned client-side at ingestion. Gives React a stable, collision-free + // key so the slice window can shift without touching existing DOM nodes. + _id: number; } export function GlobalObservabilityView() { @@ -43,6 +46,9 @@ export function GlobalObservabilityView() { // SSE throttle buffer const bufferRef = useRef([]); + // Monotonic counter for stable React keys. Incremented once per log entry + // at ingestion so duplicate-content lines never share a key. + const logIdRef = useRef(0); // Fetch settings on mount useEffect(() => { @@ -87,6 +93,7 @@ export function GlobalObservabilityView() { eventSource.onmessage = (event) => { try { const entry: LogEntry = JSON.parse(event.data); + entry._id = ++logIdRef.current; bufferRef.current.push(entry); } catch (e) { /* ignore parse errors */ } }; @@ -121,7 +128,11 @@ export function GlobalObservabilityView() { try { const logsRes = await apiFetch('/logs/global'); if (logsRes.ok) { - setLogs(await logsRes.json()); + const data: LogEntry[] = await logsRes.json(); + // Stamp each entry with a monotonic _id at ingestion so React + // has a stable, collision-free key for every log line. + data.forEach(entry => { entry._id = ++logIdRef.current; }); + setLogs(data); } } catch (error) { console.error('Failed to fetch global logs:', error); @@ -271,8 +282,8 @@ export function GlobalObservabilityView() { Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries. )} - {filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log, idx) => ( -
+ {filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => ( +
[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] [{log.containerName}] {log.level}: