Merge pull request #42 from AnsoCode/fix/remote-ws-stats-exec-openapp

fix(remote): repair stats, bash exec, and Open App for remote nodes
This commit is contained in:
Anso
2026-03-19 22:48:23 -04:00
committed by GitHub
5 changed files with 31 additions and 10 deletions
+4
View File
@@ -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.
+10 -6
View File
@@ -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
+2 -1
View File
@@ -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 = () => {
+9 -3
View File
@@ -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');
}}
>
<ExternalLink className="w-4 h-4" />
</Button>
+6
View File
@@ -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,
},
},
},