diff --git a/CHANGELOG.md b/CHANGELOG.md index a0986cab..67b234a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **(Planned)** Port 2375 (TCP) fallback support; future releases may require SSH-only for Node config. ### Fixed +- Fixed backend MonitorService crash (`Cannot read properties of undefined (reading 'cpu_usage')`) occurring when Docker containers lacked CPU telemetry during transition states. +- Handled UI deleted nodes ghost API calls by intercepting 404 errors globally in API and forcing the UI to resync to the default Node context. +- Hardened `nodeContextMiddleware` in Express to intercept queries to invalid or deleted Node IDs gracefully instead of bubbling to Docker API 500 crashes. +- Hardened Remote Node connection testing (`docker.info()`) to explicitly validate expected Docker API daemon properties instead of merely checking string length. +- Caught Unhandled SFTP Promise Rejections in Node registry gracefully returning empty arrays to prevent frontend loading UI stalls. +- Fixed horizontal UI overflowing in Node Manager settings on smaller resolutions. - **Fixed:** Docker API parsing bug where HTML string responses from misconfigured ports were counted as containers. - **Fixed:** Stack list crashing when SFTP connections fail by gracefully catching SSH errors and returning empty arrays. diff --git a/backend/src/index.ts b/backend/src/index.ts index e04e874f..dd5c4bdb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -67,6 +67,16 @@ const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) } else { req.nodeId = NodeRegistry.getInstance().getDefaultNodeId(); } + + // Intercept requests to deleted nodes to prevent downstream errors + if (req.path.startsWith('/api/') && !req.path.startsWith('/api/auth/')) { + const node = DatabaseService.getInstance().getNode(req.nodeId); + if (!node) { + res.status(404).json({ error: `Node with id ${req.nodeId} not found or was deleted.` }); + return; + } + } + next(); }; diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 1905ae0a..12a40a57 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -311,8 +311,10 @@ export class MonitorService { private calculateCpuPercent(stats: any): number { let cpuPercent = 0.0; + if (!stats?.cpu_stats?.cpu_usage || !stats?.precpu_stats?.cpu_usage) return 0.0; + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; - const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const systemDelta = (stats.cpu_stats.system_cpu_usage || 0) - (stats.precpu_stats.system_cpu_usage || 0); const numCpus = stats.cpu_stats.online_cpus || (stats.cpu_stats.cpu_usage.percpu_usage ? stats.cpu_stats.cpu_usage.percpu_usage.length : 1); if (systemDelta > 0.0 && cpuDelta > 0.0) { @@ -322,6 +324,8 @@ 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/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx index d209a29d..0dfa0423 100644 --- a/frontend/src/context/NodeContext.tsx +++ b/frontend/src/context/NodeContext.tsx @@ -48,6 +48,16 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { const updatedActive = data.find((n: Node) => n.id === activeNode.id); if (updatedActive) { setActiveNodeState(updatedActive); + } else { + // The active node was deleted! Fallback to default + const defaultNode = data.find((n: Node) => n.is_default); + if (defaultNode) { + setActiveNodeState(defaultNode); + } else if (data.length > 0) { + setActiveNodeState(data[0]); + } else { + setActiveNodeState(null); + } } } } @@ -65,7 +75,15 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { useEffect(() => { refreshNodes(); - }, []); + + const handleNodeNotFound = () => { + console.warn('[NodeContext] Active node is unreachable or deleted. Forcing sync...'); + refreshNodes(); + }; + + window.addEventListener('node-not-found', handleNodeNotFound); + return () => window.removeEventListener('node-not-found', handleNodeNotFound); + }, [refreshNodes]); return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d73683be..da58afe6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -24,6 +24,19 @@ export async function apiFetch( throw new Error('Unauthorized'); } + // Intercept 404 Node Not Found responses and force context refresh + if (response.status === 404) { + try { + const clone = response.clone(); + const errData = await clone.json(); + if (errData.error && errData.error.includes('not found') && errData.error.includes('Node')) { + window.dispatchEvent(new Event('node-not-found')); + } + } catch (e) { + // Ignore JSON parse errors, caller handles standard 404s + } + } + return response; }