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;