From 74964b0e264f856bb3f8204496a57f48bfbbbe7e Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 12:13:34 -0400 Subject: [PATCH] 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;