mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-04 16:07:55 +00:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent" onScroll={handleScroll}>
|
||||
{filteredLogs.length > 0 ? (
|
||||
<>
|
||||
{filteredLogs.map((log, idx) => (
|
||||
{filteredLogs.length > MAX_DISPLAY_ROWS && (
|
||||
<div className="text-gray-600 italic text-xs text-center mb-3 py-1 border-b border-gray-800">
|
||||
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
|
||||
</div>
|
||||
)}
|
||||
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log, idx) => (
|
||||
<div key={idx} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
||||
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
||||
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
|
||||
|
||||
Reference in New Issue
Block a user