From 3b2634f9dd0bc2a8386028605aa935b62f6e1dd9 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 09:57:01 -0400 Subject: [PATCH 1/4] fix(dashboard): surface server error messages in create-stack flow - Read JSON error body from non-ok responses before throwing, so status-specific messages (409 already exists, 400 invalid name) reach the user instead of generic hardcoded strings - Apply defensive toast pattern: error?.message || error?.error || fallback --- CHANGELOG.md | 1 + frontend/src/components/HomeDashboard.tsx | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a217ee4a..eeea30f9 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:** `HomeDashboard` create-stack error handling — HTTP error responses were thrown as hardcoded strings, discarding the server's actual error message (e.g. "Stack already exists", "Invalid stack name"). Now reads the JSON error body before throwing, and uses the defensive `error?.message || error?.error || fallback` toast pattern. - **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/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 355eae46..a71b45f6 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -174,23 +174,29 @@ export default function HomeDashboard() { method: 'POST', body: JSON.stringify({ stackName }), }); - if (!createResponse.ok) throw new Error('Failed to create stack'); + if (!createResponse.ok) { + const err = await createResponse.json().catch(() => ({})); + throw new Error(err.error || 'Failed to create stack'); + } // Save the converted YAML content const saveResponse = await apiFetch(`/stacks/${stackName}`, { method: 'PUT', body: JSON.stringify({ content: convertedYaml }), }); - if (!saveResponse.ok) throw new Error('Failed to save stack content'); + if (!saveResponse.ok) { + const err = await saveResponse.json().catch(() => ({})); + throw new Error(err.error || 'Failed to save stack content'); + } setCreateDialogOpen(false); setNewStackName(''); setConvertedYaml(''); setDockerRunInput(''); window.location.reload(); // Refresh to show new stack - } catch (error) { + } catch (error: any) { console.error('Failed to create stack:', error); - toast.error('Failed to create stack'); + toast.error(error?.message || error?.error || 'Failed to create stack'); } }; From 0db6c946e7dcb38de84346fdd3d0f38450ee3eef Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 11:59:24 -0400 Subject: [PATCH 2/4] 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 --- CHANGELOG.md | 1 + backend/src/index.ts | 6 ++++-- .../components/GlobalObservabilityView.tsx | 21 +++++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) 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() {
{filteredLogs.length > 0 ? ( <> - {filteredLogs.map((log, idx) => ( + {filteredLogs.length > MAX_DISPLAY_ROWS && ( +
+ 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) => (
[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] [{log.containerName}] From 753b0c35399f0e564f40970328212ed06393e9d3 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 12:06:36 -0400 Subject: [PATCH 3/4] fix(logs): use monotonic _id key to prevent O(n) DOM mutations on scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit key={idx} with a shifting .slice(-300) window meant every new log arrival caused React to update all 300 existing DOM nodes (index 1 became 0, 2 became 1, etc.), even though only one entry actually changed. The proposed content-hash fix (timestamp + containerName + message.substring) carries a real collision risk: chatty containers emitting identical lines at the same millisecond produce duplicate keys, triggering undefined React reconciliation behaviour. Fix: add a _id: number field to LogEntry, stamped client-side with a monotonic counter (logIdRef) at the point of ingestion — once in the SSE onmessage handler and once in the polling setLogs path. React now tracks data identity rather than position, so the slice window can shift without touching the 299 unchanged DOM nodes. --- CHANGELOG.md | 2 +- .../src/components/GlobalObservabilityView.tsx | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) 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}: From 74964b0e264f856bb3f8204496a57f48bfbbbe7e Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 12:13:34 -0400 Subject: [PATCH 4/4] fix(stats): throttle container stat WebSocket updates via ref buffer Each container's onmessage handler was calling setContainerStats() independently, causing React to schedule up to N separate reconciliation passes per second (one per container). With 20 containers streaming Docker stats at ~1 update/s, EditorLayout was re-rendering up to 20 times/s. Fix: incoming stats are written into pendingStatsRef (no re-render cost), then flushed to React state in one batched setContainerStats call every 1.5s. Two bugs in the original proposal are addressed: - rawBytesRef (never cleared) owns rx/tx tracking so net I/O rate is always accurate; avoids the stale containerStats closure that would have shown 0 B/s after every flush cycle - pending snapshot is captured and cleared BEFORE calling setState so the functional updater stays pure (no side-effects inside it) - pendingStatsRef is cleared in the effect cleanup so stale entries from the previous stack don't briefly appear on stack switch --- CHANGELOG.md | 1 + frontend/src/components/EditorLayout.tsx | 86 +++++++++++++++--------- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6d3ed4..6b608d34 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:** Container stats WebSocket flooding React with up to 20+ `setState` calls per second in `EditorLayout` — each container's `onmessage` handler called `setContainerStats` independently, causing React to schedule a separate reconciliation pass per container per second. Replaced with a ref-buffer + 1.5 s flush interval pattern: incoming stats are written to `pendingStatsRef` (no re-render cost), snapshotted and cleared before a single batched `setContainerStats` call every 1.5 s. A separate `rawBytesRef` (never cleared) tracks raw rx/tx bytes for accurate rate calculation, avoiding the stale-closure bug that would have produced 0 B/s net I/O after every flush cycle. Buffer is also cleared on cleanup so stale entries from the previous stack don't flash in on stack switch. - **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:** `HomeDashboard` create-stack error handling — HTTP error responses were thrown as hardcoded strings, discarding the server's actual error message (e.g. "Stack already exists", "Invalid stack name"). Now reads the JSON error body before throwing, and uses the defensive `error?.message || error?.error || fallback` toast pattern. - **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. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 7138c711..6f25632b 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import Editor from '@monaco-editor/react'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; @@ -67,6 +67,13 @@ export default function EditorLayout() { const [selectedEnvFile, setSelectedEnvFile] = useState(''); const [containers, setContainers] = useState([]); const [containerStats, setContainerStats] = useState>({}); + // Incoming WebSocket stats are written here first (no re-render), then flushed + // to React state in one batched update every 1.5 s. + const pendingStatsRef = useRef>({}); + // Raw rx/tx byte totals used for rate calculation. Never cleared on flush so + // the delta is always computed against the most recent known value, avoiding + // the stale-closure bug that occurs when reading containerStats directly. + const rawBytesRef = useRef>({}); const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -221,6 +228,7 @@ export default function EditorLayout() { useEffect(() => { const wsMap: Record = {}; + (containers || []).forEach(container => { if (!container?.Id) return; try { @@ -238,6 +246,7 @@ export default function EditorLayout() { const data = JSON.parse(event.data); // Skip initial empty chunks where stats fields are missing if (!data.cpu_stats?.cpu_usage || !data.precpu_stats?.cpu_usage || !data.memory_stats?.usage) return; + const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage; const systemDelta = (data.cpu_stats.system_cpu_usage || 0) - (data.precpu_stats.system_cpu_usage || 0); const onlineCpus = data.cpu_stats.online_cpus || 1; @@ -253,31 +262,23 @@ export default function EditorLayout() { }); } - setContainerStats(prev => { - const prevStat = prev[container.Id]; - // Calculate rate if we have a previous value - const rxRate = prevStat?.lastRx !== undefined ? Math.max(0, currentRx - prevStat.lastRx) : 0; - const txRate = prevStat?.lastTx !== undefined ? Math.max(0, currentTx - prevStat.lastTx) : 0; + // Rate is derived from rawBytesRef which is never cleared on flush, + // so the delta is always accurate — no stale-closure risk. + const prevRaw = rawBytesRef.current[container.Id]; + const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0; + const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0; + rawBytesRef.current[container.Id] = { lastRx: currentRx, lastTx: currentTx }; - const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`; + const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`; - // Check if values actually changed to prevent infinite re-renders - const newCpu = cpuPercent + '%'; - if (prevStat && prevStat.cpu === newCpu && prevStat.ram === ramUsage && prevStat.lastRx === currentRx && prevStat.lastTx === currentTx) { - return prev; - } - - return { - ...prev, - [container.Id]: { - cpu: newCpu, - ram: ramUsage, - net: netIO, - lastRx: currentRx, - lastTx: currentTx - } - }; - }); + // Write into the buffer ref only — zero re-render cost. + pendingStatsRef.current[container.Id] = { + cpu: cpuPercent + '%', + ram: ramUsage, + net: netIO, + lastRx: currentRx, + lastTx: currentTx, + }; } catch { // Ignore parse errors } @@ -286,16 +287,39 @@ export default function EditorLayout() { // Ignore WebSocket errors } }); - return () => { - Object.values(wsMap).forEach(ws => { - try { - ws.close(); - } catch { - // Ignore close errors + + // Flush buffered stats into React state once every 1.5 s. + // Snapshot + clear the buffer BEFORE calling setState so the updater + // function remains pure (no side-effects inside it). + const flushInterval = setInterval(() => { + const pending = pendingStatsRef.current; + if (Object.keys(pending).length === 0) return; + pendingStatsRef.current = {}; + + setContainerStats(prev => { + let hasChanges = false; + const next = { ...prev }; + for (const [id, newStats] of Object.entries(pending)) { + const old = prev[id]; + if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) { + next[id] = newStats; + hasChanges = true; + } } + return hasChanges ? next : prev; + }); + }, 1500); + + return () => { + clearInterval(flushInterval); + // Discard buffered stats for the old stack so stale entries don't + // briefly appear when a new stack is selected. + pendingStatsRef.current = {}; + Object.values(wsMap).forEach(ws => { + try { ws.close(); } catch { /* ignore */ } }); }; - }, [containers]); + }, [containers]); // eslint-disable-line react-hooks/exhaustive-deps const loadFile = async (filename: string) => { if (!filename) return;