Each container's onmessage handler was calling setContainerStats()
independently, causing React to schedule up to N separate reconciliation
passes per second (one per container). With 20 containers streaming Docker
stats at ~1 update/s, EditorLayout was re-rendering up to 20 times/s.
Fix: incoming stats are written into pendingStatsRef (no re-render cost),
then flushed to React state in one batched setContainerStats call every 1.5s.
Two bugs in the original proposal are addressed:
- rawBytesRef (never cleared) owns rx/tx tracking so net I/O rate is always
accurate; avoids the stale containerStats closure that would have shown
0 B/s after every flush cycle
- pending snapshot is captured and cleared BEFORE calling setState so the
functional updater stays pure (no side-effects inside it)
- pendingStatsRef is cleared in the effect cleanup so stale entries from
the previous stack don't briefly appear on stack switch
key={idx} with a shifting .slice(-300) window meant every new log arrival
caused React to update all 300 existing DOM nodes (index 1 became 0, 2
became 1, etc.), even though only one entry actually changed.
The proposed content-hash fix (timestamp + containerName + message.substring)
carries a real collision risk: chatty containers emitting identical lines at
the same millisecond produce duplicate keys, triggering undefined React
reconciliation behaviour.
Fix: add a _id: number field to LogEntry, stamped client-side with a
monotonic counter (logIdRef) at the point of ingestion — once in the SSE
onmessage handler and once in the polling setLogs path. React now tracks
data identity rather than position, so the slice window can shift without
touching the 299 unchanged DOM nodes.
Playwright investigation revealed the Logs view (GlobalObservabilityView)
rendered 1,859+ log entries as real DOM nodes (9,616 total DOM nodes) with
no virtualization. Combined with a 5-second polling cycle replacing all
React elements each time and smooth-scroll animations stacking on every
update, the renderer process grew rapidly on a host running at 97% RAM
usage, crashing the browser tab with Out of Memory within minutes.
Fixes:
- Cap rendered DOM rows to MAX_DISPLAY_ROWS (300) via .slice(-300) so the
browser only ever holds ~1,500 log-related DOM nodes regardless of how
many entries are in state
- Add a truncation notice when log count exceeds the display cap
- Reduce SSE-mode in-memory log cap from 10,000 to MAX_LOG_ENTRIES (2,000)
- Switch auto-scroll from behavior:'smooth' to behavior:'instant' to stop
stacking layout animations on every 5-second poll
- Reduce /api/logs/global response limit from 2,000 to 500 lines since the
client renders at most 300 rows, making the extra payload wasteful
- Destroy Docker stats stream on WS close to prevent orphaned daemon polling
- Guard all ws.send() calls with readyState === OPEN check
- Add .catch() to unawaited streamStats/execContainer calls to prevent
unhandled rejections crashing the process (Node >= 15)
- Close per-connection WebSocket.Server instances after handleUpgrade to
prevent listener accumulation over many connections
- Invert auth token precedence to bearerToken || cookieToken in both
authMiddleware and the WS upgrade handler so node-to-node Bearer tokens
are never shadowed by a stale browser cookie
- Narrow proxyRes type in remoteNodeProxy error handler before calling
.status() to avoid throwing on raw Socket (WS/TCP-level proxy errors)
Two related auth/routing bugs broke Terminal logs, container stats, the
LogViewer SSE stream, and exec bash when a remote node was selected:
1. WebSocket proxy (upgrade handler) was forwarding the browser's
`cookie` header to the remote Sencho instance. The remote's
`authMiddleware` evaluates `cookieToken || bearerToken`, so it
picked the cookie first — signed with the *local* JWT secret — and
returned 401 before even seeing the valid Bearer token.
Fix: `delete req.headers['cookie']` before `wsProxyServer.ws()`,
matching the `proxyReq.removeHeader('cookie')` already present in
the HTTP proxy.
2. WebSocket proxy was also forwarding `?nodeId=<gatewayId>` in the
URL. The remote's `nodeContextMiddleware` rejected it with 404
("Node X not found") because gateway node IDs don't exist on the
remote instance.
Fix: strip `nodeId` from `req.url` before proxying, so the remote
defaults cleanly to its own local node.
3. HTTP proxy (`remoteNodeProxy`) was forwarding `?nodeId=<gatewayId>`
in `proxyReq.path` to the remote. Affected EventSource endpoints
like `/api/containers/:id/logs?nodeId=9` that pass nodeId as a
query param rather than the `x-node-id` header.
Fix: strip `nodeId` from `proxyReq.path` in `onProxyReq`.
- 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.
The remoteNodeProxy middleware (line 373) is already positioned before
all API route definitions, so remote requests are correctly proxied to
the target Sencho instance before any route handler executes.
However, /api/system/stats had a dead remote branch that called
NodeRegistry.getDocker() for remote nodes. This method throws by design
("remote nodes are not directly accessible") since no direct Docker TCP
socket is used for remote nodes. The thrown error propagated through the
catch block, returning a 500 to the frontend and causing system stats to
show as loading/unavailable for remote nodes.
Removed the dead branch. Remote requests for /api/system/stats are now
correctly proxied, returning real CPU/RAM/disk data from the remote host.
Adds an ImageUpdateService that queries OCI registry manifest endpoints
every 6 hours to compare remote digests against local RepoDigests.
Results are cached in a new stack_update_status SQLite table. A pulsing
blue dot badge appears in the stack sidebar for stacks with updates
available. Manual refresh available via POST /api/image-updates/refresh
with a 10-minute rate limit.
Task 1 - Network layer fixes:
- AppStoreView: replace raw fetch() with apiFetch() on /templates and
/templates/deploy so x-node-id header is injected for proxy routing
- GlobalObservabilityView: replace raw fetch() with apiFetch() on
/stacks and /logs/global
- HostConsole: append ?nodeId= to WebSocket URL so the upgrade handler
correctly routes the PTY session to the active remote node
Task 2 - Scoped context two-tier UX (Option A / Portainer-style):
- EditorLayout: add a context pill in the top header bar showing the
active node name (pulsing blue for remote, green for local)
- HostConsole: header now reads "Host Console - [Node Name]"
- ResourcesView: title now reads "Resources Hub - [Node Name]" for remote
- GlobalObservabilityView: floating node badge in top-left corner for remote
- AppStoreView: deploy sheet shows "Deploying to: [Node Name]" badge
- SettingsModal: remote nodes only see System Limits + Developer tabs;
global-only tabs (Account, Appearance, Notifications, Nodes) are hidden;
header subtitle shows remote node name
Two backend fixes:
1. proxyReq was forwarding the browser's sencho_token cookie to the remote
Sencho. The remote's authMiddleware uses cookieToken || bearerToken, so
the invalid local-signed cookie was verified before the valid Bearer token,
returning 401 on every proxied API call. Strip the cookie header in
proxyReq so remote auth uses only the Bearer token.
2. nodeContextMiddleware blocked /api/nodes when x-node-id referenced a
deleted node, trapping the frontend in an unrecoverable 404 loop (it
could not fetch the nodes list to discover the node was gone). Exempt
/api/nodes alongside /api/auth/ so the app can always self-heal.
app.use('/api/', ...) causes Express to strip the '/api/' prefix from
req.url before http-proxy-middleware sees it, so the proxy was forwarding
'/stats' to the remote instead of '/api/stats'. The remote Sencho found
no matching route, fell through to its SPA catch-all, and returned
index.html (200 text/html) — explaining the SyntaxError on res.json().
Added pathRewrite: (path) => '/api' + path to restore the full path.
The connection test passed because testRemoteConnection calls the remote
directly via axios with the full URL, bypassing the proxy entirely.
- HomeDashboard: both polling useEffects now depend on activeNode?.id so
intervals restart and stale local-node data is cleared immediately on
node switch; added res.ok guard before calling res.json() to prevent
error objects being written into stats/systemStats state
- EditorLayout: refreshStacks now guards res.ok before parsing the JSON
body and iterates a typed fileList instead of the raw response value,
preventing SyntaxError/TypeError when proxy returns a non-JSON response
for an unreachable remote node
Add Node modal — type selector & state reset:
- Restored a Local/Remote <Select> dropdown in renderFormFields so users can
explicitly choose the node type instead of it defaulting silently to 'remote'.
- Switching type clears api_url and api_token so no stale remote credentials
carry over if a user switches from Remote to Local mid-form.
- Replaced the static "Add Remote Node" title with a dynamic one that reflects
the currently selected type ("Add Local Node" / "Add Remote Node").
- onOpenChange now resets formData to defaultFormData whenever the dialog
opens, preventing stale values from a previous session leaking in.
Remote connection details — real metrics:
- testRemoteConnection previously returned hard-coded '-' for containers,
images, and cpus after a successful auth/check ping.
- Now fires three parallel requests (Promise.allSettled) after auth passes:
/api/stats → containers total + running count
/api/system/stats → cpu.cores
/api/system/images → image list length
- Each field falls back to '-' gracefully if an endpoint is unavailable,
so a slow or older remote instance never breaks the connection test.
DEP0060 util._extend suppression:
- http-proxy@1.18.1 calls util._extend when createProxyServer() is first
invoked at runtime (NOT at import time). A process.emitWarning override
placed before the proxy instantiations intercepts only DEP0060 without
suppressing any other warnings. No package version changes needed.
Also includes linter/formatter normalisation across multiple files.
Proxy memory leak (MaxListenersExceededWarning + DEP0060):
createProxyMiddleware was instantiated inside the request handler on every
single API call. Each new instance registered fresh 'close' listeners on the
HTTP server and re-ran the http-proxy util._extend deprecated path. After ~10
requests the MaxListeners threshold was breached. Fix: declare ONE global
remoteNodeProxy at startup using the router option to dynamically resolve the
target URL per request. Listeners are registered once. ECONNREFUSED errors are
caught in the on.error handler and returned as structured 502 JSON.
Node switcher "nothing happens":
EditorLayout had a single useEffect([], []) that called refreshStacks() once
on mount. Changing the active node updated NodeContext state and localStorage
but nothing re-triggered the stack list fetch. Fix: split into two effects —
notifications polling (no dependency) and a stack-refresh effect keyed on
activeNode?.id. When the node changes, stale editor/container/file state is
cleared and the stacks for the new node are fetched.
Copy button silently failing:
navigator.clipboard.writeText() throws DOMException in non-HTTPS / non-localhost
contexts (e.g. http://192.168.x.x). The uncaught async exception silently
swallowed the success toast and state update. Fix: wrapped in try/catch with
an execCommand('copy') textarea fallback and a final error toast if both fail.
- Extend WS upgrade handler to accept Authorization: Bearer tokens as a
fallback to cookie auth. Remote Sencho instances receive proxied WS
connections carrying Bearer (no cookie), so the previous cookie-only
check caused immediate 401 rejections for all proxied log/terminal streams.
- Log token validation failures in authMiddleware (was silently swallowed,
violating no-empty-catch directive).
- Normalize api_url by stripping trailing slashes in testRemoteConnection,
the HTTP proxy target, and the WS proxy target to prevent double-slash URLs.
- Move NodeProvider inside the authenticated branch in App.tsx so it only
mounts after auth is confirmed; previously it mounted on boot causing
refreshNodes to fire before any session existed
- Replace window.location.href='/' on 401 in apiFetch with a
sencho-unauthorized custom event; AuthContext listens and transitions
appStatus to notAuthenticated without a full browser reload
- Cap GlobalObservabilityView SSE log array at 10,000 entries to prevent
unbounded memory growth during long dev-mode sessions
- Break NodeContext infinite re-fetch loop by replacing the activeNode
closure dependency in refreshNodes with a useRef read, making the
callback stable and preventing the useEffect -> setState -> useCallback
-> useEffect cycle
Remote nodes in the Distributed API model are self-monitoring — each
remote Sencho instance runs its own MonitorService against its local
Docker socket. The main instance must not attempt direct DockerController
access for remote nodes, which caused fatal crashes on every 30s tick.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NodeContext.tsx was missing the three TLS fields that were already
present on the backend Node type, causing TS2339 build errors in
NodeManager.tsx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- NodeRegistry: wire TLS ca/cert/key into Dockerode when present on node config
- index.ts: /api/system/stats now branches on node type — remote nodes use docker.info() for CPU/RAM, local keeps systeminformation; disk gracefully returns null for remote
- index.ts: POST /api/nodes now persists tls_ca, tls_cert, tls_key fields
- FileSystemService: throw clean error on missing/empty compose_dir instead of crashing path.join
- FileSystemService: guard getStacks() against falsy item.name entries
- SSHFileAdapter: filter undefined/non-string names from SFTP readdir before returning
- NodeManager: add SSH Authentication Type toggle (Password vs Private Key)
- NodeManager: add Enable TLS toggle with conditional CA/cert/key textarea fields
- NodeManager: auto-test connection immediately after node creation
- NodeManager: replace "Strategy B" copy with Docker TCP setup instructions
- NodeManager: add pr-8 to header and DialogHeader to prevent overlap with parent dialog X button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>