diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f592f88..1a3e8e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,23 +5,26 @@ 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:** Backend memory leak caused by improper proxy middleware instantiation — `createProxyMiddleware` was called inside the request handler on every API call, spawning a new `http-proxy` instance (and registering new server listeners) per request. Refactored to a single globally-instantiated proxy using the `router` option for dynamic per-request target resolution. +- **Fixed:** Restored Local/Remote type selector and fixed state resets in the Add Node modal — form now resets to defaults every time the dialog opens, and the title reflects the chosen type dynamically. +- **Fixed:** Remote node connection details failing to display Containers, Images, and CPU metrics — `testRemoteConnection` now fires parallel requests to `/api/stats`, `/api/system/stats`, and `/api/system/images` after auth succeeds, mapping real values into the info panel. +- **Fixed:** Suppressed `[DEP0060] DeprecationWarning: util._extend` from `http-proxy@1.18.1` — override is applied to `process.emitWarning` before the proxy instances are created, cleanly intercepting the warning at its call site without suppressing other warnings. +- **Fixed:** Backend memory leak caused by improper proxy middleware instantiation - `createProxyMiddleware` was called inside the request handler on every API call, spawning a new `http-proxy` instance (and registering new server listeners) per request. Refactored to a single globally-instantiated proxy using the `router` option for dynamic per-request target resolution. - **Fixed:** `[DEP0060] DeprecationWarning: util._extend` deprecation eliminated as a side-effect of the above fix (deprecation was triggered on every new `http-proxy` initialisation). -- **Fixed:** Remote node authentication failures — `authMiddleware` and WebSocket upgrade handler both accept `Authorization: Bearer` tokens (Sencho-to-Sencho proxy auth). +- **Fixed:** Remote node authentication failures - `authMiddleware` and WebSocket upgrade handler both accept `Authorization: Bearer` tokens (Sencho-to-Sencho proxy auth). - **Fixed:** Node connection testing logic updated to perform authenticated HTTP pings to `/api/auth/check` on the remote instance. -- **Fixed:** Node switcher dropdown failing to trigger data refreshes — `EditorLayout` now reacts to `activeNode` changes, re-fetching stacks and clearing stale editor/container state when the user switches nodes. -- **Fixed:** API Token copy button failing silently on HTTP / non-localhost deployments where `navigator.clipboard` is unavailable — added `try/catch` with `document.execCommand('copy')` fallback. +- **Fixed:** Node switcher dropdown failing to trigger data refreshes - `EditorLayout` now reacts to `activeNode` changes, re-fetching stacks and clearing stale editor/container state when the user switches nodes. +- **Fixed:** API Token copy button failing silently on HTTP / non-localhost deployments where `navigator.clipboard` is unavailable - added `try/catch` with `document.execCommand('copy')` fallback. - **Fixed:** Remote node authentication failures by updating middleware to support Bearer tokens in WebSocket upgrade handler (node-to-node WS proxy now authenticates correctly on the receiving instance). - **Fixed:** Node connection testing logic updated to normalize `api_url` trailing slashes before constructing the authenticated HTTP ping URL. -- **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. -- **Fixed:** Infinite page reload loop — `apiFetch` was calling `window.location.href = '/'` on every 401, causing a full browser reload before auth could complete. Replaced with a `sencho-unauthorized` custom event that `AuthContext` handles by setting `appStatus` to `notAuthenticated`. +- **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. +- **Fixed:** Infinite page reload loop - `apiFetch` was calling `window.location.href = '/'` on every 401, causing a full browser reload before auth could complete. Replaced with a `sencho-unauthorized` custom event that `AuthContext` handles by setting `appStatus` to `notAuthenticated`. - **Fixed:** `NodeProvider` was mounted outside the auth gate in `App.tsx`, causing `refreshNodes` to fire before authentication was established (hitting 401 immediately on boot). Moved `NodeProvider` inside the authenticated branch so it only mounts after login. - **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`). -- **Changed:** Node Manager UI vastly simplified — remote nodes now only require an API URL and Token. -- **Fixed:** Critical port routing conflict — separated Docker API port (`port`) from SSH/SFTP port (`ssh_port`) in the `nodes` schema. Previously, a single `port` field served both protocols, causing ECONNREFUSED. +- **Changed:** Node Manager UI vastly simplified - remote nodes now only require an API URL and Token. +- **Fixed:** Critical port routing conflict - separated Docker API port (`port`) from SSH/SFTP port (`ssh_port`) in the `nodes` schema. Previously, a single `port` field served both protocols, causing ECONNREFUSED. - **Fixed:** `FileSystemService` now reads the node's `compose_dir` from the database for remote nodes instead of always using the `COMPOSE_DIR` env var. - **Fixed:** SSH/SFTP connections in `SSHFileAdapter`, `ComposeService.executeRemote()`, and `ComposeService.streamLogs()` now use `ssh_port` (default 22) instead of Docker API `port`. - **Added:** Full SSH credential fields (SSH Port, Username, Password, Private Key) to the Node Manager Add/Edit forms. diff --git a/backend/src/index.ts b/backend/src/index.ts index 94b11e77..14eb588e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,6 +29,18 @@ import fs, { promises as fsPromises } from 'fs'; const execAsync = promisify(exec); +// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls +// util._extend internally. The warning fires at runtime when createProxyServer() is +// first invoked (NOT at import time), so intercepting process.emitWarning here — +// before the proxy instances are created below — fully prevents it. +// http-proxy has no compatible update; this suppression is intentional and safe. +const _origEmitWarning = process.emitWarning.bind(process); +(process as any).emitWarning = (warning: any, ...args: any[]) => { + const code = typeof args[0] === 'object' ? args[0]?.code : args[1]; + if (code === 'DEP0060') return; + _origEmitWarning(warning, ...args); +}; + const app = express(); const PORT = 3000; @@ -98,7 +110,7 @@ declare global { const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true }); wsProxyServer.on('error', (err, _req, socket: any) => { console.error('[WS Proxy] Error:', err.message); - try { socket?.destroy(); } catch {} + try { socket?.destroy(); } catch { } }); // Authentication Middleware @@ -288,7 +300,7 @@ app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, r res.status(500).json({ error: 'No JWT secret configured on this instance.' }); return; } - // No expiry — this token is managed by the admin who pastes it into the main dashboard + // No expiry - this token is managed by the admin who pastes it into the main dashboard const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret); res.json({ token }); } catch (error: any) { @@ -305,7 +317,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { authMiddleware(req, res, next); }); -// Remote Node HTTP Proxy — single global instance. +// Remote Node HTTP Proxy - single global instance. // Previously, createProxyMiddleware was called inside the request handler on every API // call, spawning a new proxy instance (and http-proxy server) each time. This caused: // - MaxListenersExceededWarning: repeated 'close' listeners added to [Server] @@ -313,7 +325,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => { // Fix: create ONE instance at startup; use the router option to resolve the // target URL dynamically per request without constructing new listeners. const remoteNodeProxy = createProxyMiddleware({ - target: 'http://localhost:0', // placeholder — overridden per-request by router + target: 'http://localhost:0', // placeholder - overridden per-request by router changeOrigin: true, router: (req) => { const node = NodeRegistry.getInstance().getNode(req.nodeId); @@ -322,7 +334,7 @@ const remoteNodeProxy = createProxyMiddleware({ on: { proxyReq: (proxyReq, req) => { const node = NodeRegistry.getInstance().getNode(req.nodeId); - // Remote Sencho sees itself as local — strip node context and inject bearer auth + // Remote Sencho sees itself as local - strip node context and inject bearer auth proxyReq.removeHeader('x-node-id'); if (node?.api_token) { proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`); @@ -406,7 +418,7 @@ server.on('upgrade', async (req, socket, head) => { const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); const node = NodeRegistry.getInstance().getNode(nodeId); - // Remote Node WebSocket Proxy — forward the entire WS connection to the remote Sencho instance + // Remote Node WebSocket Proxy - forward the entire WS connection to the remote Sencho instance if (node && node.type === 'remote' && node.api_url && node.api_token) { const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); req.headers['authorization'] = `Bearer ${node.api_token}`; @@ -495,7 +507,7 @@ wss.on('connection', (ws) => { dockerController.execContainer(data.containerId, ws); } } catch (error) { - // Malformed JSON — ignore silently + // Malformed JSON - ignore silently } }); }); @@ -1117,7 +1129,7 @@ app.get('/api/system/stats', async (req: Request, res: Response) => { const txSec = Math.max(0, globalDockerNetwork.txSec); if (node && node.type === 'remote') { - // Remote node: use Docker daemon info for CPU/RAM — disk is not available via Docker API + // Remote node: use Docker daemon info for CPU/RAM - disk is not available via Docker API const docker = NodeRegistry.getInstance().getDocker(nodeId); const info = await docker.info(); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index e869f3f7..7cbb2855 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -6,7 +6,7 @@ import { LogFormatter } from './LogFormatter'; import { NodeRegistry } from './NodeRegistry'; /** - * ComposeService — local docker compose CLI execution. + * ComposeService - local docker compose CLI execution. * * In the Distributed API model, remote node compose operations are handled * by the remote Sencho instance. This service only executes commands locally. @@ -160,7 +160,7 @@ export class ComposeService { let localProcesses: ReturnType[] = []; const onWsClose = () => { - localProcesses.forEach(cp => { try { cp.kill(); } catch {} }); + localProcesses.forEach(cp => { try { cp.kill(); } catch { } }); }; ws.on('close', onWsClose); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 8c3adcb0..6d9467e6 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -350,7 +350,7 @@ class DockerController { await container.start(); } catch (error: any) { if (error?.statusCode === 304) { - // Container already running — not an error + // Container already running - not an error return; } throw error; @@ -364,7 +364,7 @@ class DockerController { await container.stop(); } catch (error: any) { if (error?.statusCode === 304) { - // Container already stopped — not an error + // Container already stopped - not an error return; } throw error; @@ -445,7 +445,7 @@ class DockerController { /** * Exec into a container with full session isolation. - * All state (exec instance, stream) lives in this closure — no singleton traps. + * All state (exec instance, stream) lives in this closure - no singleton traps. * The WebSocket message handler is registered here to handle input, resize, and cleanup. */ public async execContainer(containerId: string, ws: WebSocket) { @@ -516,7 +516,7 @@ class DockerController { break; } } catch { - // Non-JSON or malformed message — ignore + // Non-JSON or malformed message - ignore } }); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index a9da607a..91e06bca 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -4,7 +4,7 @@ import { promises as fsPromises } from 'fs'; import { NodeRegistry } from './NodeRegistry'; /** - * FileSystemService — local-only file I/O for compose stack management. + * FileSystemService - local-only file I/O for compose stack management. * * In the Distributed API model, remote node file operations are handled * by the remote Sencho instance itself. This service only operates on diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index eefec7bd..3881a2c5 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -121,7 +121,7 @@ export class MonitorService { const nodes = DatabaseService.getInstance().getNodes(); for (const node of nodes) { if (!node.id) continue; - // Remote nodes run their own MonitorService locally — skip direct Docker access + // Remote nodes run their own MonitorService locally - skip direct Docker access if (node.type === 'remote') continue; try { const docker = DockerController.getInstance(node.id); @@ -208,7 +208,7 @@ export class MonitorService { for (const node of nodes) { if (!node.id) continue; - // Remote nodes are self-monitoring — skip direct Docker access + // Remote nodes are self-monitoring - skip direct Docker access if (node.type === 'remote') continue; try { const docker = DockerController.getInstance(node.id); @@ -329,7 +329,7 @@ export class MonitorService { private calculateMemoryPercent(stats: any): number { if (!stats?.memory_stats?.usage || !stats?.memory_stats?.limit) return 0.0; - + const used_memory = stats.memory_stats.usage - (stats.memory_stats.stats?.cache || 0); const available_memory = stats.memory_stats.limit; if (available_memory > 0) { diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts index 85a22d6c..940f3714 100644 --- a/backend/src/services/NodeRegistry.ts +++ b/backend/src/services/NodeRegistry.ts @@ -14,7 +14,7 @@ export class NodeRegistry { private static instance: NodeRegistry; private connections: Map = new Map(); - private constructor() {} + private constructor() { } public static getInstance(): NodeRegistry { if (!NodeRegistry.instance) { @@ -25,7 +25,7 @@ export class NodeRegistry { /** * Get a Docker client for a LOCAL node only. - * Remote nodes are never accessed via Dockerode — use the HTTP proxy instead. + * Remote nodes are never accessed via Dockerode - use the HTTP proxy instead. */ public getDocker(nodeId: number): Docker { if (this.connections.has(nodeId)) { @@ -42,7 +42,7 @@ export class NodeRegistry { if (node.type === 'remote') { throw new Error( `Node "${node.name}" is a remote Distributed API node. ` + - `Its Docker daemon is not directly accessible — all requests are proxied via HTTP.` + `Its Docker daemon is not directly accessible - all requests are proxied via HTTP.` ); } @@ -153,36 +153,46 @@ export class NodeRegistry { return { success: false, error: 'Remote node is missing an API URL or token. Configure it in Settings → Nodes.' }; } + const baseUrl = node.api_url.replace(/\/$/, ''); + const headers = { Authorization: `Bearer ${node.api_token}` }; + try { - const baseUrl = node.api_url.replace(/\/$/, ''); - const response = await axios.get(`${baseUrl}/api/auth/check`, { - headers: { Authorization: `Bearer ${node.api_token}` }, - timeout: 8000, - }); + // Step 1: Verify auth. A 401 here means wrong token — surface that clearly. + const authRes = await axios.get(`${baseUrl}/api/auth/check`, { headers, timeout: 8000 }); + if (authRes.status !== 200) throw new Error(`Unexpected status ${authRes.status}`); - if (response.status === 200) { - db.updateNodeStatus(node.id, 'online'); - return { - success: true, - info: { - name: node.name, - serverVersion: 'Remote Sencho', - os: 'Remote', - architecture: 'Remote', - containers: '—', - containersRunning: '—', - images: '—', - memTotal: 0, - cpus: '—', - } - }; - } + db.updateNodeStatus(node.id, 'online'); - throw new Error(`Unexpected status ${response.status}`); + // Step 2: Fetch Docker stats in parallel. Use allSettled so a slow or missing + // endpoint doesn't fail the whole test — each field falls back to '-' gracefully. + const [statsResult, sysResult, imagesResult] = await Promise.allSettled([ + axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }), + axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }), + axios.get(`${baseUrl}/api/system/images`, { headers, timeout: 8000 }), + ]); + + const stats = statsResult.status === 'fulfilled' ? statsResult.value.data : null; + const sys = sysResult.status === 'fulfilled' ? sysResult.value.data : null; + const images = imagesResult.status === 'fulfilled' ? imagesResult.value.data : null; + + return { + success: true, + info: { + name: node.name, + serverVersion: 'Remote Sencho', + os: 'Remote', + architecture: 'Remote', + containers: stats?.total ?? '-', + containersRunning: stats?.active ?? '-', + images: Array.isArray(images) ? images.length : '-', + memTotal: sys?.memory?.total ?? 0, + cpus: sys?.cpu?.cores ?? '-', + } + }; } catch (error: any) { db.updateNodeStatus(node.id, 'offline'); const msg = error.response?.status === 401 - ? 'Authentication failed — check the API token.' + ? 'Authentication failed - check the API token.' : (error.message || 'Connection failed'); return { success: false, error: msg }; } diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index bf3a8806..5dd76be8 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -75,7 +75,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN return; } - // Node exists and has layout — safe to initialize xterm! + // Node exists and has layout - safe to initialize xterm! initTerminal(container); }; @@ -160,7 +160,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN setIsConnected(false); }; - // Handle user input — JSON up + // Handle user input - JSON up term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ @@ -224,9 +224,9 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN Interactive bash terminal session for {containerName} - {/* Styling wrapper — padding and rounded corners go here */} + {/* Styling wrapper - padding and rounded corners go here */}
- {/* Clean xterm container — NO padding, NO overflow-hidden, explicit dimensions */} + {/* Clean xterm container - NO padding, NO overflow-hidden, explicit dimensions */}
{ fetchNotifications(); const notificationInterval = setInterval(fetchNotifications, 5000); @@ -208,10 +208,10 @@ export default function EditorLayout() { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${wsProtocol}//${window.location.host}`); wsMap[container.Id] = ws; - ws.onopen = () => ws.send(JSON.stringify({ - action: 'streamStats', - containerId: container.Id, - nodeId: localStorage.getItem('sencho-active-node') || undefined + ws.onopen = () => ws.send(JSON.stringify({ + action: 'streamStats', + containerId: container.Id, + nodeId: localStorage.getItem('sencho-active-node') || undefined })); ws.onmessage = (event) => { try { @@ -688,10 +688,9 @@ export default function EditorLayout() { {nodes.map(node => (
-
+
{node.name}
diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 75b5fabb..9f24a07e 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -12,6 +12,7 @@ import { Badge } from './ui/badge'; import { Separator } from './ui/separator'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check } from 'lucide-react'; interface NodeFormData { @@ -199,7 +200,7 @@ export function NodeManager() { toast.success('Token copied to clipboard'); setTimeout(() => setTokenCopied(false), 2000); } catch { - toast.error('Could not copy automatically — please select and copy the token manually.'); + toast.error('Could not copy automatically - please select and copy the token manually.'); } } }; @@ -233,6 +234,32 @@ export function NodeManager() { />
+
+ + +
+ {formData.type === 'remote' && ( <>
@@ -292,7 +319,14 @@ export function NodeManager() { Manage connections to local and remote Sencho instances

- + { + setCreateOpen(open); + // Reset form to defaults every time the dialog opens + if (open) setFormData(defaultFormData); + }} + >