From 94018d167fc09070c1baac99b0cdaf7f35dd7751 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Thu, 19 Mar 2026 22:45:00 -0400 Subject: [PATCH] fix(remote): repair stats, bash exec, and Open App for remote nodes - Generic WebSockets (stats, exec) now connect to /ws?nodeId= instead of the bare root so the upgrade handler detects the remote node and proxies the WS connection to the correct remote Sencho instance. - Vite dev proxy gains ws:true on /api and a new /ws entry so WebSocket upgrades reach localhost:3000 during npm run dev. - Backend WS message handler falls back to the default local node when the nodeId in a proxied message doesn't exist in the local DB. - Open App button now extracts the hostname from the remote node's api_url instead of using window.location.hostname. --- CHANGELOG.md | 4 ++++ backend/src/index.ts | 16 ++++++++++------ frontend/src/components/BashExecModal.tsx | 3 ++- frontend/src/components/EditorLayout.tsx | 12 +++++++++--- frontend/vite.config.ts | 6 ++++++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76212264..16adfade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ 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 (CPU/RAM/NET), bash exec terminal, and "Open App" button all broken for remote nodes — stats and exec WebSockets connected to the bare root (`ws://host`) with no `?nodeId=` query param, so the upgrade handler couldn't detect the remote node and skipped the WS proxy. Moved all generic WebSockets to `/ws?nodeId=` path; upgrade handler now correctly proxies them to the remote Sencho instance. +- **Fixed:** Bash exec and stats WebSockets not reaching the backend in `npm run dev` — Vite proxy config now includes `ws: true` on `/api` and a new `/ws` proxy entry so all WebSocket upgrades are forwarded to `localhost:3000`. +- **Fixed:** Backend WS message handler crashing when a proxied WebSocket arrives from a gateway and the forwarded `nodeId` doesn't exist in the remote instance's DB — now falls back to the default local node instead of throwing. +- **Fixed:** "Open App" button opening `http://localhost:{port}` for remote node containers — now resolves the hostname from the remote node's `api_url` so the correct remote host is used. - **Fixed:** Remote node system stats, container stats, logs, and exec returning errors — `remoteNodeProxy` middleware was already positioned before API route definitions, but `/api/system/stats` contained a dead remote branch that called `NodeRegistry.getDocker()` for remote nodes, which throws since remote nodes have no direct Docker socket access. Removed the broken branch; remote requests are correctly intercepted by the proxy middleware before reaching any route handler. - **Added:** Background image update checker — `ImageUpdateService` polls OCI-compliant registries (Docker Hub, GHCR, LSCR, etc.) every 6 hours using manifest digest comparison against local `RepoDigests`. Results cached in a new `stack_update_status` SQLite table. A pulsing blue dot badge appears in the stack list sidebar for stacks with available updates. Manual refresh available via `POST /api/image-updates/refresh` (rate-limited to once per 10 minutes). - **Fixed:** `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` — all calls now inject the `x-node-id` header so templates, deploys, stacks, and logs are correctly proxied to the active remote node. diff --git a/backend/src/index.ts b/backend/src/index.ts index 082e560a..e50969d5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -513,15 +513,19 @@ wss.on('connection', (ws) => { if (data.action === 'connectTerminal') { terminalWs = ws; } else if (data.action === 'streamStats') { - const nodeId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); - const dockerController = DockerController.getInstance(nodeId); - dockerController.streamStats(data.containerId, ws); + const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); + // When a WS is proxied from a gateway to this remote instance, the nodeId in the + // message belongs to the gateway's DB and won't resolve locally. Fall back to local. + let nodeId = requestedId; + try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); } + DockerController.getInstance(nodeId).streamStats(data.containerId, ws); } else if (data.action === 'execContainer') { // Handle container exec for bash access // Input, resize, and cleanup are handled inside execContainer's closure - const nodeId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); - const dockerController = DockerController.getInstance(nodeId); - dockerController.execContainer(data.containerId, ws); + const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId(); + let nodeId = requestedId; + try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); } + DockerController.getInstance(nodeId).execContainer(data.containerId, ws); } } catch (error) { // Malformed JSON - ignore silently diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index 5dd76be8..fa172c85 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -114,7 +114,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN // Connect to WebSocket for bash exec const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${wsProtocol}//${window.location.host}`); + const activeNodeId = localStorage.getItem('sencho-active-node') || ''; + const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`); wsRef.current = ws; ws.onopen = () => { diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 8f1b6383..7138c711 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -225,12 +225,13 @@ export default function EditorLayout() { if (!container?.Id) return; try { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${wsProtocol}//${window.location.host}`); + const activeNodeId = localStorage.getItem('sencho-active-node') || ''; + const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`); wsMap[container.Id] = ws; ws.onopen = () => ws.send(JSON.stringify({ action: 'streamStats', containerId: container.Id, - nodeId: localStorage.getItem('sencho-active-node') || undefined + nodeId: activeNodeId || undefined })); ws.onmessage = (event) => { try { @@ -1099,7 +1100,12 @@ export default function EditorLayout() { size="sm" variant="ghost" className="rounded-lg h-8 w-8" - onClick={() => window.open(`http://${window.location.hostname}:${mainPort}`, '_blank')} + onClick={() => { + const host = activeNode?.type === 'remote' && activeNode?.api_url + ? new URL(activeNode.api_url).hostname + : window.location.hostname; + window.open(`http://${host}:${mainPort}`, '_blank'); + }} > diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0ad8d303..5ea7361d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -16,6 +16,12 @@ export default defineConfig({ '/api': { target: 'http://localhost:3000', changeOrigin: true, + ws: true, + }, + '/ws': { + target: 'http://localhost:3000', + changeOrigin: true, + ws: true, }, }, },