From 0cb5fae947ac69b5e3b6c06c82d2f2870e6600de Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 22:25:29 -0400 Subject: [PATCH 1/2] feat(design): animated design system foundation with animate-ui and motion Install motion + animate-ui, overhaul design tokens with brand cyan accent, and replace CSS keyframe animations in Dialog, Tabs, Switch, and Tooltip with spring-physics and blur-fade transitions via animate-ui Radix primitives. --- CHANGELOG.md | 97 +- backend/src/index.ts | 23 +- backend/src/services/DatabaseService.ts | 2 +- backend/src/services/ImageUpdateService.ts | 8 +- backend/src/services/NodeRegistry.ts | 4 +- backend/src/services/NotificationService.ts | 2 +- backend/src/services/TemplateService.ts | 2 +- frontend/index.html | 3 + frontend/package-lock.json | 1599 ++++++++++++++++- frontend/package.json | 4 + frontend/src/components/EditorLayout.tsx | 292 +-- frontend/src/components/HostConsole.tsx | 2 +- frontend/src/components/NodeManager.tsx | 4 +- frontend/src/components/ResourcesView.tsx | 2 +- frontend/src/components/SettingsModal.tsx | 56 +- .../animate-ui/primitives/animate/slot.tsx | 96 + .../primitives/effects/auto-height.tsx | 55 + .../animate-ui/primitives/effects/fade.tsx | 93 + .../primitives/effects/highlight.tsx | 640 +++++++ .../animate-ui/primitives/radix/dialog.tsx | 207 +++ .../primitives/radix/dropdown-menu.tsx | 563 ++++++ .../primitives/radix/hover-card.tsx | 207 +++ .../animate-ui/primitives/radix/popover.tsx | 162 ++ .../animate-ui/primitives/radix/sheet.tsx | 191 ++ .../animate-ui/primitives/radix/switch.tsx | 155 ++ .../animate-ui/primitives/radix/tabs.tsx | 189 ++ .../animate-ui/primitives/radix/tooltip.tsx | 220 +++ .../primitives/texts/counting-number.tsx | 119 ++ .../primitives/texts/sliding-number.tsx | 353 ++++ frontend/src/components/ui/dialog.tsx | 129 +- frontend/src/components/ui/switch.tsx | 50 +- frontend/src/components/ui/tabs.tsx | 52 +- frontend/src/components/ui/tooltip.tsx | 38 +- frontend/src/hooks/use-auto-height.tsx | 102 ++ frontend/src/hooks/use-controlled-state.tsx | 33 + frontend/src/hooks/use-data-state.tsx | 54 + frontend/src/hooks/use-is-in-view.tsx | 25 + frontend/src/index.css | 206 ++- frontend/src/lib/get-strict-context.tsx | 36 + 39 files changed, 5634 insertions(+), 441 deletions(-) create mode 100644 frontend/src/components/animate-ui/primitives/animate/slot.tsx create mode 100644 frontend/src/components/animate-ui/primitives/effects/auto-height.tsx create mode 100644 frontend/src/components/animate-ui/primitives/effects/fade.tsx create mode 100644 frontend/src/components/animate-ui/primitives/effects/highlight.tsx create mode 100644 frontend/src/components/animate-ui/primitives/radix/dialog.tsx create mode 100644 frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx create mode 100644 frontend/src/components/animate-ui/primitives/radix/hover-card.tsx create mode 100644 frontend/src/components/animate-ui/primitives/radix/popover.tsx create mode 100644 frontend/src/components/animate-ui/primitives/radix/sheet.tsx create mode 100644 frontend/src/components/animate-ui/primitives/radix/switch.tsx create mode 100644 frontend/src/components/animate-ui/primitives/radix/tabs.tsx create mode 100644 frontend/src/components/animate-ui/primitives/radix/tooltip.tsx create mode 100644 frontend/src/components/animate-ui/primitives/texts/counting-number.tsx create mode 100644 frontend/src/components/animate-ui/primitives/texts/sliding-number.tsx create mode 100644 frontend/src/hooks/use-auto-height.tsx create mode 100644 frontend/src/hooks/use-controlled-state.tsx create mode 100644 frontend/src/hooks/use-data-state.tsx create mode 100644 frontend/src/hooks/use-is-in-view.tsx create mode 100644 frontend/src/lib/get-strict-context.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 406976e6..a070f0e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,53 +5,64 @@ 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] -- **Changed:** Notification delivery replaced polling with WebSocket push — `EditorLayout` no longer runs a `setInterval` to fetch `/api/notifications` every 5 seconds. A persistent `ws://host/ws/notifications` connection is opened on mount; the backend pushes each new alert in real-time as it is dispatched. Auto-reconnects with a 5-second back-off on close. -- **Added:** `NotificationService.setBroadcaster()` — injectable callback wired to a `notificationSubscribers` Set in `index.ts`; every authenticated WS client subscribed to `/ws/notifications` receives a `{ type: 'notification', payload: NotificationHistory }` message the moment `dispatchAlert` fires. +- **Added:** `motion` package and `animate-ui` animated component library as the animation foundation for the design system. +- **Changed:** Design system overhauled — new brand cyan token (`--brand`, `oklch(0.72 0.14 200)` dark / `oklch(0.50 0.14 200)` light) applied to focus rings across both themes. CSS custom properties added for motion easing curves (`--ease-spring`, `--ease-out-expo`), animation durations, and stagger delay utilities. `prefers-reduced-motion` respected globally. +- **Changed:** Geist font loaded via Google Fonts CDN (was declared but never imported — no visual change, now actually resolved). +- **Changed:** Dark mode shadow system strengthened (opacity 0.18 → 0.35) for better depth on dark backgrounds. Scrollbar thumb uses correct `oklch()` values (was using `hsl(var(--muted))` which broke with OKLCH token format). +- **Changed:** `ui/dialog.tsx` — replaced CSS keyframe animations with animate-ui's spring-based 3D perspective flip (open: `rotateX(-20deg) scale(0.8)` → `scale(1)`, overlays fade + blur). +- **Changed:** `ui/tabs.tsx` — tab content transitions now use motion blur-fade (`filter: blur(4px)` → `blur(0px)`) instead of static CSS `animate-in`. +- **Changed:** `ui/switch.tsx` — thumb movement animated via spring physics (`stiffness: 300, damping: 25`); press gesture scales thumb by 1.15 for tactile feedback. +- **Changed:** `ui/tooltip.tsx` — tooltips pop in with spring scale animation (`stiffness: 350, damping: 28`) instead of CSS zoom-in. +- **Changed:** `EditorLayout` main workspace container keyed to `activeView` so every view switch triggers a `fade-up` entrance animation. +- **Fixed:** animate-ui `auto-height.tsx` imported `WithAsChild` without the `type` keyword, causing Vite to include a runtime import for a TypeScript-only type, crashing the browser module loader. +- **Fixed:** animate-ui `switch.tsx` double-spread all props (including Radix-only `onCheckedChange`) onto the underlying `motion.button` DOM element, triggering React `Unknown event handler property` warnings. Radix props are now explicitly destructured and passed only to `SwitchPrimitives.Root`. +- **Changed:** Notification delivery replaced polling with WebSocket push - `EditorLayout` no longer runs a `setInterval` to fetch `/api/notifications` every 5 seconds. A persistent `ws://host/ws/notifications` connection is opened on mount; the backend pushes each new alert in real-time as it is dispatched. Auto-reconnects with a 5-second back-off on close. +- **Added:** `NotificationService.setBroadcaster()` - injectable callback wired to a `notificationSubscribers` Set in `index.ts`; every authenticated WS client subscribed to `/ws/notifications` receives a `{ type: 'notification', payload: NotificationHistory }` message the moment `dispatchAlert` fires. - **Changed:** `DatabaseService.addNotificationHistory` now returns the full inserted `NotificationHistory` record (including auto-generated `id` and `is_read: false`) instead of `void`, enabling the broadcaster to push the complete record to clients. -- **Added:** `/ws/notifications` WebSocket upgrade path in `index.ts` — handled before the remote-node proxy path so it is always local. JWT auth verified manually (same pattern as all other WS paths per Directive 8). -- **Security:** `GET /api/settings` no longer leaks `auth_username`, `auth_password_hash`, or `auth_jwt_secret` to the frontend — these keys are stripped from the response before sending. -- **Security:** `POST /api/settings` now enforces a strict allowlist of writable keys — attempts to write auth credential keys (`auth_jwt_secret`, `auth_password_hash`, etc.) or arbitrary unknown keys are rejected with a 400 error. -- **Added:** `PATCH /api/settings` bulk-update endpoint — accepts a partial settings object, validates all values via a Zod schema (type checking, range enforcement, URL format validation), and persists changes atomically in a single SQLite transaction. Replaces the N+1 per-key POST loop. -- **Added:** `system_state` SQLite table — separates runtime operational state (e.g. janitor alert cooldown timestamp) from user-defined configuration in `global_settings`. `MonitorService` now writes `last_janitor_alert_timestamp` to `system_state` instead of `global_settings`, eliminating false positives in future audit logging. -- **Added:** `metrics_retention_hours` (default: 24h) and `log_retention_days` (default: 30d) configurable settings — `MonitorService` now reads these dynamically each cycle instead of using hardcoded values. Notification history is pruned on the same cycle as container metrics. -- **Fixed:** Developer settings (`developer_mode`, `global_logs_refresh`, `metrics_retention_hours`, `log_retention_days`) are now correctly scoped to the local Sencho instance — when a remote node is active, `fetchSettings` performs a secondary `localOnly` fetch for these keys and `saveDeveloperSettings` always writes to local via the new `apiFetch({ localOnly: true })` option, preventing UI preferences from being proxied into remote nodes' databases. -- **Added:** `localOnly` option on `apiFetch` — omits the `x-node-id` header so requests always route to the local node regardless of the active node context. +- **Added:** `/ws/notifications` WebSocket upgrade path in `index.ts` - handled before the remote-node proxy path so it is always local. JWT auth verified manually (same pattern as all other WS paths per Directive 8). +- **Security:** `GET /api/settings` no longer leaks `auth_username`, `auth_password_hash`, or `auth_jwt_secret` to the frontend - these keys are stripped from the response before sending. +- **Security:** `POST /api/settings` now enforces a strict allowlist of writable keys - attempts to write auth credential keys (`auth_jwt_secret`, `auth_password_hash`, etc.) or arbitrary unknown keys are rejected with a 400 error. +- **Added:** `PATCH /api/settings` bulk-update endpoint - accepts a partial settings object, validates all values via a Zod schema (type checking, range enforcement, URL format validation), and persists changes atomically in a single SQLite transaction. Replaces the N+1 per-key POST loop. +- **Added:** `system_state` SQLite table - separates runtime operational state (e.g. janitor alert cooldown timestamp) from user-defined configuration in `global_settings`. `MonitorService` now writes `last_janitor_alert_timestamp` to `system_state` instead of `global_settings`, eliminating false positives in future audit logging. +- **Added:** `metrics_retention_hours` (default: 24h) and `log_retention_days` (default: 30d) configurable settings - `MonitorService` now reads these dynamically each cycle instead of using hardcoded values. Notification history is pruned on the same cycle as container metrics. +- **Fixed:** Developer settings (`developer_mode`, `global_logs_refresh`, `metrics_retention_hours`, `log_retention_days`) are now correctly scoped to the local Sencho instance - when a remote node is active, `fetchSettings` performs a secondary `localOnly` fetch for these keys and `saveDeveloperSettings` always writes to local via the new `apiFetch({ localOnly: true })` option, preventing UI preferences from being proxied into remote nodes' databases. +- **Added:** `localOnly` option on `apiFetch` - omits the `x-node-id` header so requests always route to the local node regardless of the active node context. - **UI:** System Limits badge on remote nodes now reads "Configuring: [node name]"; Developer tab badge reads "Always Local" to make scope explicit to the user. -- **Refactor:** `SettingsModal` frontend overhauled — per-operation loading states replace the single shared `isLoading` flag (saving system settings no longer disables notification test buttons). Settings are fetched before UI is interactive (skeleton loader blocks premature saves). Only known patchable keys are hydrated into component state (auth keys can never enter React state). Unsaved-changes dot indicator on sidebar nav items. All saves use the new `PATCH /api/settings` endpoint. Developer tab gains a "Data Retention" section for metrics and log retention controls. -- **Added:** App Store category filter — LSIO templates are now grouped into categories (Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, Other) via a static lookup map in `TemplateService`. A horizontal pill bar below the search field lets users filter by category; clicking a category badge on a template card also activates the filter. Category badges highlight when their category is the active filter. App count updates reactively. -- **Added:** App Store registry settings — new "App Store" section in Settings lets users supply a custom Portainer v2 JSON template URL to override the default LinuxServer.io registry. "Save & Refresh" persists the URL and immediately busts the 24-hour template cache via `POST /api/templates/refresh-cache`. Portainer v2 registries pass their native `categories` field through unchanged. -- **Added:** `source` field on the `Template` interface — set to `'linuxserver'` for LSIO apps and `'custom'` for Portainer v2 registries, enabling future per-source filtering. -- **Fixed:** Container stats WebSocket flooding React with up to 20+ `setState` calls per second in `EditorLayout` — each container's `onmessage` handler called `setContainerStats` independently, causing React to schedule a separate reconciliation pass per container per second. Replaced with a ref-buffer + 1.5 s flush interval pattern: incoming stats are written to `pendingStatsRef` (no re-render cost), snapshotted and cleared before a single batched `setContainerStats` call every 1.5 s. A separate `rawBytesRef` (never cleared) tracks raw rx/tx bytes for accurate rate calculation, avoiding the stale-closure bug that would have produced 0 B/s net I/O after every flush cycle. Buffer is also cleared on cleanup so stale entries from the previous stack don't flash in on stack switch. -- **Fixed:** Browser Out of Memory crash in `GlobalObservabilityView` (Logs view) — the component rendered all log entries (up to 1,859+) as real DOM nodes with no virtualization, causing ~9,600 DOM nodes and ~25 MB of GC pressure every 5-second poll cycle. On RAM-constrained hosts (host system was at 97% usage) the browser renderer process OOMed within minutes. Fixed by capping DOM rendering to the last 300 entries (`MAX_DISPLAY_ROWS`), reducing the in-memory SSE log cap from 10,000 to 2,000 entries (`MAX_LOG_ENTRIES`), switching auto-scroll from `behavior: 'smooth'` (stacked layout animations) to `behavior: 'instant'`, and reducing the backend polling response limit from 2,000 to 500 lines. Also replaced `key={idx}` (array index) with a monotonic `_id` counter stamped at ingestion time so the slice window shifting no longer forces O(n) DOM mutations per new log line — React now only reconciles the one entry that actually changed. -- **Fixed:** `HomeDashboard` create-stack error handling — HTTP error responses were thrown as hardcoded strings, discarding the server's actual error message (e.g. "Stack already exists", "Invalid stack name"). Now reads the JSON error body before throwing, and uses the defensive `error?.message || error?.error || fallback` toast pattern. -- **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes — the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal. -- **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes — the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node. -- **Fixed:** `streamStats` Docker stats stream leaking after WebSocket client disconnect — the Docker daemon stream was never destroyed when the WS closed, causing orphaned streams to poll the daemon indefinitely. Fixed by adding a `ws.on('close')` handler that calls `stats.destroy()`. Also guards all `ws.send()` calls with a `readyState === OPEN` check to prevent silent errors on a closed socket. -- **Fixed:** `streamStats` and `execContainer` called unawaited in the WS `connection` handler — unhandled promise rejections (e.g., invalid container ID, Docker daemon unavailable) would terminate the Node.js process in production (Node ≥ 15). Fixed by chaining `.catch()` on both calls, logging the error, and closing the WebSocket cleanly. -- **Fixed:** Per-connection `WebSocket.Server` instances for stack logs and host console never closed after upgrade — each WS connection allocated a persistent `ws.Server` object accumulating listeners. Fixed by calling `wss.close()` immediately after `handleUpgrade` completes. -- **Fixed:** `authMiddleware` and WS upgrade handler evaluated `cookieToken || bearerToken` — a browser cookie present alongside a valid Bearer token would shadow it, risking 401 on node-to-node proxy calls. Fixed by inverting precedence to `bearerToken || cookieToken` in both places. -- **Fixed:** `remoteNodeProxy` error handler unsafely cast `proxyRes` to `Response` — on WebSocket or TCP-level proxy errors `proxyRes` is a raw `Socket`, so calling `.status()` would throw. Fixed by type-narrowing before sending the 502 response. -- **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. -- **Fixed:** `HostConsole` WebSocket URL missing `?nodeId=` query parameter — the upgrade handler now receives the active node ID and routes the PTY session to the correct node. -- **Added:** Two-tier Option A scoped navigation UX — a context pill in the top header bar always shows the active node name (pulsing blue for remote, green for local). -- **Added:** Remote-aware headers in `HostConsole` ("Host Console — [Node Name]"), `ResourcesView` ("Resources Hub — [Node Name]"), `GlobalObservabilityView` (floating node badge), and `AppStoreView` deploy sheet ("Deploying to: [Node Name]"). -- **Added:** `SettingsModal` now scopes its sidebar to the active node type — when a remote node is selected, global-only tabs (Account, Appearance, Notifications, Nodes) are hidden, and the header subtitle shows the remote node name. +- **Refactor:** `SettingsModal` frontend overhauled - per-operation loading states replace the single shared `isLoading` flag (saving system settings no longer disables notification test buttons). Settings are fetched before UI is interactive (skeleton loader blocks premature saves). Only known patchable keys are hydrated into component state (auth keys can never enter React state). Unsaved-changes dot indicator on sidebar nav items. All saves use the new `PATCH /api/settings` endpoint. Developer tab gains a "Data Retention" section for metrics and log retention controls. +- **Added:** App Store category filter - LSIO templates are now grouped into categories (Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, Other) via a static lookup map in `TemplateService`. A horizontal pill bar below the search field lets users filter by category; clicking a category badge on a template card also activates the filter. Category badges highlight when their category is the active filter. App count updates reactively. +- **Added:** App Store registry settings - new "App Store" section in Settings lets users supply a custom Portainer v2 JSON template URL to override the default LinuxServer.io registry. "Save & Refresh" persists the URL and immediately busts the 24-hour template cache via `POST /api/templates/refresh-cache`. Portainer v2 registries pass their native `categories` field through unchanged. +- **Added:** `source` field on the `Template` interface - set to `'linuxserver'` for LSIO apps and `'custom'` for Portainer v2 registries, enabling future per-source filtering. +- **Fixed:** Container stats WebSocket flooding React with up to 20+ `setState` calls per second in `EditorLayout` - each container's `onmessage` handler called `setContainerStats` independently, causing React to schedule a separate reconciliation pass per container per second. Replaced with a ref-buffer + 1.5 s flush interval pattern: incoming stats are written to `pendingStatsRef` (no re-render cost), snapshotted and cleared before a single batched `setContainerStats` call every 1.5 s. A separate `rawBytesRef` (never cleared) tracks raw rx/tx bytes for accurate rate calculation, avoiding the stale-closure bug that would have produced 0 B/s net I/O after every flush cycle. Buffer is also cleared on cleanup so stale entries from the previous stack don't flash in on stack switch. +- **Fixed:** Browser Out of Memory crash in `GlobalObservabilityView` (Logs view) - the component rendered all log entries (up to 1,859+) as real DOM nodes with no virtualization, causing ~9,600 DOM nodes and ~25 MB of GC pressure every 5-second poll cycle. On RAM-constrained hosts (host system was at 97% usage) the browser renderer process OOMed within minutes. Fixed by capping DOM rendering to the last 300 entries (`MAX_DISPLAY_ROWS`), reducing the in-memory SSE log cap from 10,000 to 2,000 entries (`MAX_LOG_ENTRIES`), switching auto-scroll from `behavior: 'smooth'` (stacked layout animations) to `behavior: 'instant'`, and reducing the backend polling response limit from 2,000 to 500 lines. Also replaced `key={idx}` (array index) with a monotonic `_id` counter stamped at ingestion time so the slice window shifting no longer forces O(n) DOM mutations per new log line - React now only reconciles the one entry that actually changed. +- **Fixed:** `HomeDashboard` create-stack error handling - HTTP error responses were thrown as hardcoded strings, discarding the server's actual error message (e.g. "Stack already exists", "Invalid stack name"). Now reads the JSON error body before throwing, and uses the defensive `error?.message || error?.error || fallback` toast pattern. +- **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes - the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal. +- **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes - the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node. +- **Fixed:** `streamStats` Docker stats stream leaking after WebSocket client disconnect - the Docker daemon stream was never destroyed when the WS closed, causing orphaned streams to poll the daemon indefinitely. Fixed by adding a `ws.on('close')` handler that calls `stats.destroy()`. Also guards all `ws.send()` calls with a `readyState === OPEN` check to prevent silent errors on a closed socket. +- **Fixed:** `streamStats` and `execContainer` called unawaited in the WS `connection` handler - unhandled promise rejections (e.g., invalid container ID, Docker daemon unavailable) would terminate the Node.js process in production (Node ≥ 15). Fixed by chaining `.catch()` on both calls, logging the error, and closing the WebSocket cleanly. +- **Fixed:** Per-connection `WebSocket.Server` instances for stack logs and host console never closed after upgrade - each WS connection allocated a persistent `ws.Server` object accumulating listeners. Fixed by calling `wss.close()` immediately after `handleUpgrade` completes. +- **Fixed:** `authMiddleware` and WS upgrade handler evaluated `cookieToken || bearerToken` - a browser cookie present alongside a valid Bearer token would shadow it, risking 401 on node-to-node proxy calls. Fixed by inverting precedence to `bearerToken || cookieToken` in both places. +- **Fixed:** `remoteNodeProxy` error handler unsafely cast `proxyRes` to `Response` - on WebSocket or TCP-level proxy errors `proxyRes` is a raw `Socket`, so calling `.status()` would throw. Fixed by type-narrowing before sending the 502 response. +- **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. +- **Fixed:** `HostConsole` WebSocket URL missing `?nodeId=` query parameter - the upgrade handler now receives the active node ID and routes the PTY session to the correct node. +- **Added:** Two-tier Option A scoped navigation UX - a context pill in the top header bar always shows the active node name (pulsing blue for remote, green for local). +- **Added:** Remote-aware headers in `HostConsole` ("Host Console - [Node Name]"), `ResourcesView` ("Resources Hub - [Node Name]"), `GlobalObservabilityView` (floating node badge), and `AppStoreView` deploy sheet ("Deploying to: [Node Name]"). +- **Added:** `SettingsModal` now scopes its sidebar to the active node type - when a remote node is selected, global-only tabs (Account, Appearance, Notifications, Nodes) are hidden, and the header subtitle shows the remote node name. - **Fixed:** A massive memory leak (browser Out of Memory crash) by throttling historical metrics polling down to 60s and downsampling SQLite metrics payload sizes by 12x. - **Fixed:** A bug where the active node UI dropdown would desync from the actual API requests on initial page load by properly hydrating state from localStorage. -- **Fixed:** Remote node proxy forwarding the browser's `sencho_token` cookie to the remote Sencho instance — the remote's `authMiddleware` evaluates `cookieToken || bearerToken` and the cookie (signed with the local JWT secret) was validated before the valid Bearer token, causing 401 on all proxied API calls. Fixed by stripping the `cookie` header in `proxyReq` so only the Bearer token is used for remote authentication. -- **Fixed:** `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted/non-existent node — the nodes list endpoint must always succeed so the frontend can re-sync a stale node ID in localStorage; exempted alongside `/api/auth/`. -- **Fixed:** Remote node proxy stripping the `/api` path prefix — `remoteNodeProxy` is mounted at `app.use('/api/', ...)` so Express strips that prefix from `req.url` before `http-proxy-middleware` sees it; added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to the remote Sencho instance (e.g. `/stats` → `/api/stats`). This was the root cause of all remote API calls returning the remote's SPA HTML instead of JSON. -- **Fixed:** Dashboard cards (Active Containers, Host CPU, Host RAM, Docker Network) showing stale local-node data after switching to a remote node — `HomeDashboard` polling effects now depend on `activeNode?.id` and clear state immediately on node change. -- **Fixed:** `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response (e.g., connection refused to unreachable remote node) — now checks `res.ok` before calling `res.json()` and iterates a typed `fileList` instead of the raw parsed value. -- **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:** Remote node proxy forwarding the browser's `sencho_token` cookie to the remote Sencho instance - the remote's `authMiddleware` evaluates `cookieToken || bearerToken` and the cookie (signed with the local JWT secret) was validated before the valid Bearer token, causing 401 on all proxied API calls. Fixed by stripping the `cookie` header in `proxyReq` so only the Bearer token is used for remote authentication. +- **Fixed:** `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted/non-existent node - the nodes list endpoint must always succeed so the frontend can re-sync a stale node ID in localStorage; exempted alongside `/api/auth/`. +- **Fixed:** Remote node proxy stripping the `/api` path prefix - `remoteNodeProxy` is mounted at `app.use('/api/', ...)` so Express strips that prefix from `req.url` before `http-proxy-middleware` sees it; added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to the remote Sencho instance (e.g. `/stats` → `/api/stats`). This was the root cause of all remote API calls returning the remote's SPA HTML instead of JSON. +- **Fixed:** Dashboard cards (Active Containers, Host CPU, Host RAM, Docker Network) showing stale local-node data after switching to a remote node - `HomeDashboard` polling effects now depend on `activeNode?.id` and clear state immediately on node change. +- **Fixed:** `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response (e.g., connection refused to unreachable remote node) - now checks `res.ok` before calling `res.json()` and iterates a typed `fileList` instead of the raw parsed value. +- **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). diff --git a/backend/src/index.ts b/backend/src/index.ts index 068b29d5..5c8202d0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -32,8 +32,8 @@ 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. +// 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[]) => { @@ -359,7 +359,7 @@ const remoteNodeProxy = createProxyMiddleware({ proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`); } // Strip the ?nodeId= query param so the remote's nodeContextMiddleware - // doesn't reject the request with 404 ("Node X not found") — the remote + // doesn't reject the request with 404 ("Node X not found") - the remote // has no record of the gateway's node IDs and should treat the request // as local. This affects endpoints like EventSource /api/containers/:id/logs // that pass nodeId as a query param rather than the x-node-id header. @@ -375,7 +375,7 @@ const remoteNodeProxy = createProxyMiddleware({ console.error('[Proxy] Remote node error:', (err as Error).message); // proxyRes can be either a ServerResponse (HTTP) or a raw Socket (WS/TCP errors). // Only attempt to send an HTTP 502 if it is a proper ServerResponse with a - // headersSent flag — otherwise silently drop (the socket will be destroyed). + // headersSent flag - otherwise silently drop (the socket will be destroyed). const res = proxyRes as any; if (typeof res?.headersSent === 'boolean' && !res.headersSent && typeof res.status === 'function') { res.status(502).json({ @@ -418,9 +418,10 @@ const wss = new WebSocket.Server({ noServer: true }); let terminalWs: WebSocket | null = null; -// Notification push — set of authenticated browser clients subscribed to real-time alerts +// Notification push - set of authenticated browser clients subscribed to real-time alerts const notificationSubscribers = new Set(); NotificationService.getInstance().setBroadcaster((notification) => { + if (notificationSubscribers.size === 0) return; const msg = JSON.stringify({ type: 'notification', payload: notification }); for (const ws of notificationSubscribers) { if (ws.readyState === WebSocket.OPEN) { @@ -461,7 +462,7 @@ server.on('upgrade', async (req, socket, head) => { const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`); const pathname = parsedUrl.pathname; - // Notification push channel — always local, never proxied to remote nodes + // Notification push channel - always local, never proxied to remote nodes if (pathname === '/ws/notifications') { const notifWss = new WebSocket.Server({ noServer: true }); notifWss.handleUpgrade(req, socket, head, (ws) => { @@ -483,7 +484,7 @@ server.on('upgrade', async (req, socket, head) => { const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws'); req.headers['authorization'] = `Bearer ${node.api_token}`; delete req.headers['x-node-id']; - // Strip the browser's session cookie — it is signed by this instance's JWT secret and + // Strip the browser's session cookie - it is signed by this instance's JWT secret and // would fail verification on the remote. Auth is handled exclusively via the Bearer token. delete req.headers['cookie']; // Strip nodeId from the forwarded URL so the remote treats the request as a local one. @@ -1096,7 +1097,7 @@ app.get('/api/logs/global', async (req: Request, res: Response) => { })); // Sort globally by timestamp ascending (newest bottom). - // Limit to 500 lines — the client renders at most 300 rows at once, so + // Limit to 500 lines - the client renders at most 300 rows at once, so // sending 2000 lines was wasting bandwidth and inflating JSON parse time. allLogs.sort((a, b) => a.timestampMs - b.timestampMs); res.json(allLogs.slice(-500)); @@ -1269,7 +1270,7 @@ app.post('/api/agents', async (req: Request, res: Response) => { } }); -// Keys that contain auth credentials — never exposed to the frontend or writable via settings API +// Keys that contain auth credentials - never exposed to the frontend or writable via settings API const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']); // Strict allowlist of keys writable via the settings API (prevents overwriting auth credentials) @@ -1286,7 +1287,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'log_retention_days', ]); -// Zod schema for bulk PATCH — all keys optional, present keys fully validated +// Zod schema for bulk PATCH - all keys optional, present keys fully validated import { z } from 'zod'; const SettingsPatchSchema = z.object({ host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String), @@ -1304,7 +1305,7 @@ const SettingsPatchSchema = z.object({ app.get('/api/settings', async (req: Request, res: Response) => { try { const settings = DatabaseService.getInstance().getGlobalSettings(); - // Strip auth credentials — these are managed exclusively by /api/auth/* endpoints + // Strip auth credentials - these are managed exclusively by /api/auth/* endpoints for (const key of PRIVATE_SETTINGS_KEYS) { delete settings[key]; } diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 4e703d10..f88c9959 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -251,7 +251,7 @@ export class DatabaseService { stmt.run(key, value); } - // --- System State (operational/runtime values — not user-defined config) --- + // --- System State (operational/runtime values - not user-defined config) --- public getSystemState(key: string): string | null { const row = this.db.prepare('SELECT value FROM system_state WHERE key = ?').get(key) as { value: string } | undefined; diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 06df6d33..8c663f74 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -109,7 +109,7 @@ async function getAuthToken(registry: string, repo: string): Promise !!t.image && t.type === 1) .map((t: Template) => ({ ...t, source: 'custom' })); diff --git a/frontend/index.html b/frontend/index.html index 6296104f..42884c56 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,9 @@ Sencho + + +