diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3339da..10480ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ 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:** Memory leak in `GlobalObservabilityView` SSE mode — log array now capped at 10,000 entries (`.slice(-10000)`) to prevent unbounded accumulation across long sessions. +- **Fixed:** Infinite re-fetch loop in `NodeContext` — `refreshNodes` useCallback no longer depends on `activeNode` state; replaced with a `useRef` to read current node inside the callback without being a reactive dependency. - **Removed:** SSH/SFTP file adapters and remote Docker TCP connections (net negative ~500 lines of code). - **Added:** Distributed API proxying using http-proxy-middleware for HTTP and WebSockets. - **Added:** Long-lived JWT generation for Sencho-to-Sencho API authentication (`POST /api/auth/generate-node-token`). diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 73205fe8..ae4d172d 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -93,9 +93,8 @@ export function GlobalObservabilityView() { const batch = bufferRef.current.splice(0); setLogs(prev => { const merged = [...prev, ...batch]; - // Infinite scroll: no slice limit in dev mode merged.sort((a, b) => a.timestampMs - b.timestampMs); - return merged; + return merged.slice(-10000); }); } }, 500); diff --git a/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx index fbacb6f5..699c9c52 100644 --- a/frontend/src/context/NodeContext.tsx +++ b/frontend/src/context/NodeContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; import { apiFetch } from '@/lib/api'; export interface Node { @@ -27,6 +27,9 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { const [nodes, setNodes] = useState([]); const [activeNode, setActiveNodeState] = useState(null); const [isLoading, setIsLoading] = useState(true); + // Ref lets refreshNodes read current activeNode without being a dep (breaks infinite loop) + const activeNodeRef = useRef(null); + activeNodeRef.current = activeNode; const refreshNodes = useCallback(async () => { try { @@ -35,7 +38,8 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { const data = await res.json(); setNodes(data); - if (!activeNode) { + const currentActive = activeNodeRef.current; + if (!currentActive) { const defaultNode = data.find((n: Node) => n.is_default); if (defaultNode) { setActiveNodeState(defaultNode); @@ -43,7 +47,7 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { setActiveNodeState(data[0]); } } else { - const updatedActive = data.find((n: Node) => n.id === activeNode.id); + const updatedActive = data.find((n: Node) => n.id === currentActive.id); if (updatedActive) { setActiveNodeState(updatedActive); } else { @@ -63,7 +67,7 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { } finally { setIsLoading(false); } - }, [activeNode]); + }, []); // stable — reads activeNode via ref, not closure capture const setActiveNode = useCallback((node: Node) => { setActiveNodeState(node);