From abefd5e1f6b594158eb2536aba805717cc51e579 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 07:24:44 -0400 Subject: [PATCH 01/68] fix(remote): harden WS stream lifecycle, auth precedence, and proxy error handling - 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) --- CHANGELOG.md | 5 ++++ backend/src/index.ts | 33 +++++++++++++++++++----- backend/src/services/DockerController.ts | 18 ++++++++++--- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc12407..a217ee4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - **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. diff --git a/backend/src/index.ts b/backend/src/index.ts index c79ad906..af5ca4db 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -122,13 +122,15 @@ wsProxyServer.on('error', (err, _req, socket: any) => { }); // Authentication Middleware -// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy) +// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy). +// Bearer token is evaluated first: node-to-node proxy calls always carry a Bearer token and +// should never be shadowed by a stale or cross-instance cookie. const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise => { const cookieToken = req.cookies[COOKIE_NAME]; const bearerToken = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : null; - const token = cookieToken || bearerToken; + const token = bearerToken || cookieToken; if (!token) { res.status(401).json({ error: 'Authentication required' }); @@ -371,8 +373,12 @@ const remoteNodeProxy = createProxyMiddleware({ }, error: (err, _req, proxyRes) => { console.error('[Proxy] Remote node error:', (err as Error).message); - if (!(proxyRes as Response).headersSent) { - (proxyRes as Response).status(502).json({ + // 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). + const res = proxyRes as any; + if (typeof res?.headersSent === 'boolean' && !res.headersSent && typeof res.status === 'function') { + res.status(502).json({ error: 'Remote node is unreachable. Check the API URL and ensure Sencho is running on that host.' }); } @@ -424,7 +430,9 @@ server.on('upgrade', async (req, socket, head) => { const cookieToken = cookies[COOKIE_NAME]; const authHeader = req.headers['authorization'] as string | undefined; const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; - const token = cookieToken || bearerToken; + // Prefer Bearer over cookie: node-to-node proxy upgrades carry a Bearer token and must + // not be shadowed by a browser cookie signed with a different instance's JWT secret. + const token = bearerToken || cookieToken; if (!token) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); @@ -473,6 +481,10 @@ server.on('upgrade', async (req, socket, head) => { // Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs const logsWss = new WebSocket.Server({ noServer: true }); logsWss.handleUpgrade(req, socket, head, (ws) => { + // Close the per-connection server immediately after the upgrade is complete. + // The wss instance is only needed to negotiate the handshake; keeping it open + // would accumulate listeners and allocate memory for every connection. + logsWss.close(); const stackName = decodeURIComponent(logsMatch[1]); try { ComposeService.getInstance(nodeId).streamLogs(stackName, ws); @@ -486,6 +498,7 @@ server.on('upgrade', async (req, socket, head) => { } else if (hostConsoleMatch) { const hostConsoleWss = new WebSocket.Server({ noServer: true }); hostConsoleWss.handleUpgrade(req, socket, head, (ws) => { + hostConsoleWss.close(); let targetDirectory = ''; try { targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir(); @@ -539,14 +552,20 @@ wss.on('connection', (ws) => { // 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); + DockerController.getInstance(nodeId).streamStats(data.containerId, ws).catch((err: Error) => { + console.error('[WS] streamStats error:', err.message); + if (ws.readyState === WebSocket.OPEN) ws.close(); + }); } else if (data.action === 'execContainer') { // Handle container exec for bash access // Input, resize, and cleanup are handled inside execContainer's closure 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); + DockerController.getInstance(nodeId).execContainer(data.containerId, ws).catch((err: Error) => { + console.error('[WS] execContainer error:', err.message); + if (ws.readyState === WebSocket.OPEN) ws.close(); + }); } } catch (error) { // Malformed JSON - ignore silently diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 6d9467e6..5c60c7c3 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -425,15 +425,27 @@ class DockerController { const stats = await container.stats({ stream: true }); stats.on('data', (chunk: Buffer) => { - ws.send(chunk.toString()); + if (ws.readyState === WebSocket.OPEN) { + ws.send(chunk.toString()); + } }); stats.on('error', (err: Error) => { - ws.send(JSON.stringify({ error: err.message })); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ error: err.message })); + } }); stats.on('end', () => { - ws.send(JSON.stringify({ end: true })); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ end: true })); + } + }); + + // Destroy the Docker stats stream when the WebSocket closes to prevent + // orphaned streams polling the daemon after client disconnect. + ws.on('close', () => { + try { (stats as any).destroy(); } catch { /* stream already ended */ } }); } From 3b2634f9dd0bc2a8386028605aa935b62f6e1dd9 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 09:57:01 -0400 Subject: [PATCH 02/68] fix(dashboard): surface server error messages in create-stack flow - Read JSON error body from non-ok responses before throwing, so status-specific messages (409 already exists, 400 invalid name) reach the user instead of generic hardcoded strings - Apply defensive toast pattern: error?.message || error?.error || fallback --- CHANGELOG.md | 1 + frontend/src/components/HomeDashboard.tsx | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a217ee4a..eeea30f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 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:** `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. diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 355eae46..a71b45f6 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -174,23 +174,29 @@ export default function HomeDashboard() { method: 'POST', body: JSON.stringify({ stackName }), }); - if (!createResponse.ok) throw new Error('Failed to create stack'); + if (!createResponse.ok) { + const err = await createResponse.json().catch(() => ({})); + throw new Error(err.error || 'Failed to create stack'); + } // Save the converted YAML content const saveResponse = await apiFetch(`/stacks/${stackName}`, { method: 'PUT', body: JSON.stringify({ content: convertedYaml }), }); - if (!saveResponse.ok) throw new Error('Failed to save stack content'); + if (!saveResponse.ok) { + const err = await saveResponse.json().catch(() => ({})); + throw new Error(err.error || 'Failed to save stack content'); + } setCreateDialogOpen(false); setNewStackName(''); setConvertedYaml(''); setDockerRunInput(''); window.location.reload(); // Refresh to show new stack - } catch (error) { + } catch (error: any) { console.error('Failed to create stack:', error); - toast.error('Failed to create stack'); + toast.error(error?.message || error?.error || 'Failed to create stack'); } }; From 0db6c946e7dcb38de84346fdd3d0f38450ee3eef Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 11:59:24 -0400 Subject: [PATCH 03/68] fix(logs): cap DOM rendering to 300 rows to prevent OOM crash 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 --- CHANGELOG.md | 1 + backend/src/index.ts | 6 ++++-- .../components/GlobalObservabilityView.tsx | 21 +++++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a217ee4a..16f0004d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 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:** 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. - **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. diff --git a/backend/src/index.ts b/backend/src/index.ts index af5ca4db..cdd69b67 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1072,9 +1072,11 @@ app.get('/api/logs/global', async (req: Request, res: Response) => { } })); - // Sort globally by timestamp ascending (newest bottom) and limit to 2000 lines + // Sort globally by timestamp ascending (newest bottom). + // 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(-2000)); + res.json(allLogs.slice(-500)); } catch (error) { res.status(500).json({ error: 'Failed to fetch global logs' }); } diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 3ceafc2b..406987c6 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -7,6 +7,12 @@ import { RefreshCw, Download, Trash2, Search, Filter } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; +// Max entries held in React state. Bounds SSE-mode memory growth. +const MAX_LOG_ENTRIES = 2000; +// Max rows rendered as DOM nodes at once. Prevents the renderer from +// creating thousands of DOM nodes that OOM the browser on RAM-constrained hosts. +const MAX_DISPLAY_ROWS = 300; + interface LogEntry { stackName: string; @@ -96,7 +102,7 @@ export function GlobalObservabilityView() { setLogs(prev => { const merged = [...prev, ...batch]; merged.sort((a, b) => a.timestampMs - b.timestampMs); - return merged.slice(-10000); + return merged.slice(-MAX_LOG_ENTRIES); }); } }, 500); @@ -156,8 +162,10 @@ export function GlobalObservabilityView() { }, [logs, selectedStacks, streamFilter, searchQuery, clearedAt]); useEffect(() => { - if (isAutoScrollEnabled) { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + if (isAutoScrollEnabled && bottomRef.current) { + // Use instant scroll to avoid stacking smooth-scroll animations on every + // 5-second poll cycle, which wastes layout work and renderer memory. + bottomRef.current.scrollIntoView({ behavior: 'instant' }); } }, [filteredLogs, isAutoScrollEnabled]); @@ -258,7 +266,12 @@ export function GlobalObservabilityView() {
{filteredLogs.length > 0 ? ( <> - {filteredLogs.map((log, idx) => ( + {filteredLogs.length > MAX_DISPLAY_ROWS && ( +
+ Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries. +
+ )} + {filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log, idx) => (
[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] [{log.containerName}] From 753b0c35399f0e564f40970328212ed06393e9d3 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 12:06:36 -0400 Subject: [PATCH 04/68] fix(logs): use monotonic _id key to prevent O(n) DOM mutations on scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 +- .../src/components/GlobalObservabilityView.tsx | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f0004d..6992a7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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:** 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. +- **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:** 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. diff --git a/frontend/src/components/GlobalObservabilityView.tsx b/frontend/src/components/GlobalObservabilityView.tsx index 406987c6..13f7fdc4 100644 --- a/frontend/src/components/GlobalObservabilityView.tsx +++ b/frontend/src/components/GlobalObservabilityView.tsx @@ -21,6 +21,9 @@ interface LogEntry { level: string; message: string; timestampMs: number; + // Assigned client-side at ingestion. Gives React a stable, collision-free + // key so the slice window can shift without touching existing DOM nodes. + _id: number; } export function GlobalObservabilityView() { @@ -43,6 +46,9 @@ export function GlobalObservabilityView() { // SSE throttle buffer const bufferRef = useRef([]); + // Monotonic counter for stable React keys. Incremented once per log entry + // at ingestion so duplicate-content lines never share a key. + const logIdRef = useRef(0); // Fetch settings on mount useEffect(() => { @@ -87,6 +93,7 @@ export function GlobalObservabilityView() { eventSource.onmessage = (event) => { try { const entry: LogEntry = JSON.parse(event.data); + entry._id = ++logIdRef.current; bufferRef.current.push(entry); } catch (e) { /* ignore parse errors */ } }; @@ -121,7 +128,11 @@ export function GlobalObservabilityView() { try { const logsRes = await apiFetch('/logs/global'); if (logsRes.ok) { - setLogs(await logsRes.json()); + const data: LogEntry[] = await logsRes.json(); + // Stamp each entry with a monotonic _id at ingestion so React + // has a stable, collision-free key for every log line. + data.forEach(entry => { entry._id = ++logIdRef.current; }); + setLogs(data); } } catch (error) { console.error('Failed to fetch global logs:', error); @@ -271,8 +282,8 @@ export function GlobalObservabilityView() { Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
)} - {filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log, idx) => ( -
+ {filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => ( +
[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}] [{log.containerName}] {log.level}: From 74964b0e264f856bb3f8204496a57f48bfbbbe7e Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 12:13:34 -0400 Subject: [PATCH 05/68] fix(stats): throttle container stat WebSocket updates via ref buffer 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 --- CHANGELOG.md | 1 + frontend/src/components/EditorLayout.tsx | 86 +++++++++++++++--------- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6d3ed4..6b608d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 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 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. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 7138c711..6f25632b 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import Editor from '@monaco-editor/react'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; @@ -67,6 +67,13 @@ export default function EditorLayout() { const [selectedEnvFile, setSelectedEnvFile] = useState(''); const [containers, setContainers] = useState([]); const [containerStats, setContainerStats] = useState>({}); + // Incoming WebSocket stats are written here first (no re-render), then flushed + // to React state in one batched update every 1.5 s. + const pendingStatsRef = useRef>({}); + // Raw rx/tx byte totals used for rate calculation. Never cleared on flush so + // the delta is always computed against the most recent known value, avoiding + // the stale-closure bug that occurs when reading containerStats directly. + const rawBytesRef = useRef>({}); const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -221,6 +228,7 @@ export default function EditorLayout() { useEffect(() => { const wsMap: Record = {}; + (containers || []).forEach(container => { if (!container?.Id) return; try { @@ -238,6 +246,7 @@ export default function EditorLayout() { const data = JSON.parse(event.data); // Skip initial empty chunks where stats fields are missing if (!data.cpu_stats?.cpu_usage || !data.precpu_stats?.cpu_usage || !data.memory_stats?.usage) return; + const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage; const systemDelta = (data.cpu_stats.system_cpu_usage || 0) - (data.precpu_stats.system_cpu_usage || 0); const onlineCpus = data.cpu_stats.online_cpus || 1; @@ -253,31 +262,23 @@ export default function EditorLayout() { }); } - setContainerStats(prev => { - const prevStat = prev[container.Id]; - // Calculate rate if we have a previous value - const rxRate = prevStat?.lastRx !== undefined ? Math.max(0, currentRx - prevStat.lastRx) : 0; - const txRate = prevStat?.lastTx !== undefined ? Math.max(0, currentTx - prevStat.lastTx) : 0; + // Rate is derived from rawBytesRef which is never cleared on flush, + // so the delta is always accurate — no stale-closure risk. + const prevRaw = rawBytesRef.current[container.Id]; + const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0; + const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0; + rawBytesRef.current[container.Id] = { lastRx: currentRx, lastTx: currentTx }; - const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`; + const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`; - // Check if values actually changed to prevent infinite re-renders - const newCpu = cpuPercent + '%'; - if (prevStat && prevStat.cpu === newCpu && prevStat.ram === ramUsage && prevStat.lastRx === currentRx && prevStat.lastTx === currentTx) { - return prev; - } - - return { - ...prev, - [container.Id]: { - cpu: newCpu, - ram: ramUsage, - net: netIO, - lastRx: currentRx, - lastTx: currentTx - } - }; - }); + // Write into the buffer ref only — zero re-render cost. + pendingStatsRef.current[container.Id] = { + cpu: cpuPercent + '%', + ram: ramUsage, + net: netIO, + lastRx: currentRx, + lastTx: currentTx, + }; } catch { // Ignore parse errors } @@ -286,16 +287,39 @@ export default function EditorLayout() { // Ignore WebSocket errors } }); - return () => { - Object.values(wsMap).forEach(ws => { - try { - ws.close(); - } catch { - // Ignore close errors + + // Flush buffered stats into React state once every 1.5 s. + // Snapshot + clear the buffer BEFORE calling setState so the updater + // function remains pure (no side-effects inside it). + const flushInterval = setInterval(() => { + const pending = pendingStatsRef.current; + if (Object.keys(pending).length === 0) return; + pendingStatsRef.current = {}; + + setContainerStats(prev => { + let hasChanges = false; + const next = { ...prev }; + for (const [id, newStats] of Object.entries(pending)) { + const old = prev[id]; + if (!old || old.cpu !== newStats.cpu || old.ram !== newStats.ram || old.net !== newStats.net) { + next[id] = newStats; + hasChanges = true; + } } + return hasChanges ? next : prev; + }); + }, 1500); + + return () => { + clearInterval(flushInterval); + // Discard buffered stats for the old stack so stale entries don't + // briefly appear when a new stack is selected. + pendingStatsRef.current = {}; + Object.values(wsMap).forEach(ws => { + try { ws.close(); } catch { /* ignore */ } }); }; - }, [containers]); + }, [containers]); // eslint-disable-line react-hooks/exhaustive-deps const loadFile = async (filename: string) => { if (!filename) return; From 34cad76d45fe7212bd61aa514f40e13217e2fd8e Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 15:19:32 -0400 Subject: [PATCH 06/68] feat(app-store): category filter bar and custom registry settings - TemplateService: static LSIO_CATEGORY_MAP covers ~80 well-known apps across Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, and Other. Category lookup is O(1) and runs once at cache-fill time. Adds source field ('linuxserver' | 'custom') to Template for future per-source UX. Portainer v2 registries pass their native categories through unchanged. Adds clearCache() method wired to POST /api/templates/refresh-cache. - AppStoreView: category pill bar (All + sorted dynamic categories) rendered between search and grid; active pill fills on click; clicking a category badge on a card also sets the active filter; app count updates reactively; filter composed with existing search query. - SettingsModal: new App Store section (local-node only) exposes a custom Portainer v2 JSON URL input with Save & Refresh (saves setting then busts the 24h template cache) and Reset to Default. --- CHANGELOG.md | 3 + backend/src/index.ts | 5 + backend/src/services/TemplateService.ts | 162 +++++++++++++++++++++- frontend/src/components/AppStoreView.tsx | 54 ++++++-- frontend/src/components/SettingsModal.tsx | 94 ++++++++++++- 5 files changed, 306 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a217ee4a..81d594cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ 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] +- **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:** 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. diff --git a/backend/src/index.ts b/backend/src/index.ts index af5ca4db..ca70b9af 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1482,6 +1482,11 @@ app.get('/api/templates', async (req: Request, res: Response) => { } }); +app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => { + templateService.clearCache(); + res.json({ success: true }); +}); + app.post('/api/templates/deploy', async (req: Request, res: Response) => { try { const { stackName, template, envVars } = req.body; diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index fd23c86c..f69909fa 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -29,6 +29,7 @@ export interface Template { docs_url?: string; architectures?: string[]; stars?: number; + source?: string; repository?: { url: string; stackfile: string; @@ -40,11 +41,165 @@ export interface TemplatesResponse { templates: Template[]; } +// Static category map for LSIO apps (the LSIO API does not expose category metadata). +// Apps can belong to multiple categories. Unmapped apps fall back to ['Other']. +const LSIO_CATEGORY_MAP: Record = { + // Media Servers + 'plex': ['Media'], + 'jellyfin': ['Media'], + 'emby': ['Media'], + 'navidrome': ['Media'], + 'airsonic-advanced': ['Media'], + 'airsonic': ['Media'], + 'beets': ['Media'], + 'calibre': ['Media', 'Books'], + 'calibre-web': ['Media', 'Books'], + 'kavita': ['Media', 'Books'], + 'komga': ['Media', 'Books'], + 'mylar3': ['Media', 'Books'], + 'ubooquity': ['Media', 'Books'], + 'lazylibrarian': ['Media', 'Books'], + 'cops': ['Media', 'Books'], + 'photoprism': ['Media', 'Productivity'], + 'immich': ['Media', 'Productivity'], + 'piwigo': ['Media'], + 'lychee': ['Media'], + 'davos': ['Media'], + 'mstream': ['Media'], + 'koel': ['Media'], + 'grocy': ['Productivity'], + // *arr Automation suite + 'sonarr': ['Automation', 'Media'], + 'radarr': ['Automation', 'Media'], + 'lidarr': ['Automation', 'Media'], + 'readarr': ['Automation', 'Media'], + 'bazarr': ['Automation', 'Media'], + 'whisparr': ['Automation', 'Media'], + 'prowlarr': ['Automation'], + 'jackett': ['Automation'], + 'nzbhydra2': ['Automation'], + 'overseerr': ['Automation', 'Media'], + 'ombi': ['Automation', 'Media'], + 'requestrr': ['Automation'], + 'tautulli': ['Monitoring', 'Media'], + 'organizr': ['Automation'], + 'recyclarr': ['Automation'], + 'notifiarr': ['Automation'], + 'unpackerr': ['Automation'], + // Dashboards / Homepages + 'heimdall': ['Utilities'], + 'homer': ['Utilities'], + 'dasherr': ['Utilities'], + 'flame': ['Utilities'], + 'homarr': ['Utilities'], + 'dashdot': ['Monitoring'], + // Downloaders + 'qbittorrent': ['Downloaders'], + 'transmission': ['Downloaders'], + 'deluge': ['Downloaders'], + 'sabnzbd': ['Downloaders'], + 'nzbget': ['Downloaders'], + 'aria2': ['Downloaders'], + 'jdownloader-2': ['Downloaders'], + 'pyload-ng': ['Downloaders'], + 'rutorrent': ['Downloaders'], + 'flood': ['Downloaders'], + 'medusa': ['Automation', 'Downloaders'], + 'sickchill': ['Automation', 'Downloaders'], + // Monitoring + 'grafana': ['Monitoring'], + 'netdata': ['Monitoring'], + 'uptime-kuma': ['Monitoring'], + 'statping-ng': ['Monitoring'], + 'healthchecks': ['Monitoring'], + 'smokeping': ['Monitoring'], + 'librespeed': ['Monitoring'], + 'speedtest-tracker': ['Monitoring'], + 'scrutiny': ['Monitoring'], + 'prometheus': ['Monitoring'], + 'loki': ['Monitoring'], + 'influxdb': ['Monitoring'], + // Networking / Reverse Proxy + 'nginx': ['Networking'], + 'swag': ['Networking'], + 'letsencrypt': ['Networking'], + 'ddclient': ['Networking'], + 'duckdns': ['Networking'], + 'wireguard': ['Networking', 'Security'], + 'openvpn-as': ['Networking', 'Security'], + 'netbootxyz': ['Networking'], + 'pihole': ['Networking'], + 'unbound': ['Networking'], + 'adguardhome': ['Networking'], + 'cloudflared': ['Networking'], + 'haproxy': ['Networking'], + 'traefik': ['Networking'], + 'nginx-proxy-manager': ['Networking'], + 'fail2ban': ['Networking', 'Security'], + // Security / Auth + 'vaultwarden': ['Security'], + 'authelia': ['Security'], + 'lldap': ['Security'], + 'endlessh': ['Security'], + 'sshwifty': ['Security'], + // Development / CI + 'gitea': ['Development'], + 'code-server': ['Development'], + 'drone': ['Development'], + 'drone-runner-docker': ['Development'], + 'registry': ['Development'], + 'jenkins': ['Development'], + 'gogs': ['Development'], + 'woodpecker-ci': ['Development'], + 'gitlab': ['Development'], + 'fleet': ['Development'], + // Productivity / Self-hosted SaaS + 'nextcloud': ['Productivity'], + 'bookstack': ['Productivity', 'Documentation'], + 'dokuwiki': ['Productivity', 'Documentation'], + 'wikijs': ['Productivity', 'Documentation'], + 'paperless-ngx': ['Productivity'], + 'mealie': ['Productivity'], + 'freshrss': ['Productivity'], + 'miniflux': ['Productivity'], + 'wallabag': ['Productivity'], + 'trilium': ['Productivity'], + 'hedgedoc': ['Productivity'], + 'etherpad': ['Productivity'], + 'monica': ['Productivity'], + 'firefly-iii': ['Productivity'], + 'shlink': ['Productivity'], + 'yourls': ['Productivity'], + 'stirling-pdf': ['Productivity'], + 'syncthing': ['Productivity'], + 'tandoor': ['Productivity'], + 'linkwarden': ['Productivity'], + 'vikunja': ['Productivity'], + // Utilities / Backup + 'duplicati': ['Utilities'], + 'restic': ['Utilities'], + 'rsnapshot': ['Utilities'], + 'mysql-workbench': ['Utilities'], + 'sqlitebrowser': ['Utilities'], + 'filezilla': ['Utilities'], + 'rdesktop': ['Utilities'], + 'webtop': ['Utilities'], +}; + +function getCategoriesForApp(name: string): string[] { + return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other']; +} + export class TemplateService { private cachedTemplates: Template[] = []; private lastFetchTime: number = 0; private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours + public clearCache(): void { + this.cachedTemplates = []; + this.lastFetchTime = 0; + } + public async getTemplates(): Promise { const now = Date.now(); if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) { @@ -73,6 +228,8 @@ export class TemplateService { docs_url: app.readme, architectures: app.arch, stars: app.stars, + categories: getCategoriesForApp(app.name), + source: 'linuxserver', // Map configs if available, otherwise default to empty arrays ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`), volumes: (app.config?.volumes || []).map((v: any) => { @@ -91,7 +248,10 @@ export class TemplateService { }); } else { // Legacy Portainer v2 Format (Fallback for custom registries) - this.cachedTemplates = (response.data.templates || []).filter((t: Template) => !!t.image && t.type === 1); + // The Portainer v2 spec includes a native `categories` field — pass it through. + this.cachedTemplates = (response.data.templates || []) + .filter((t: Template) => !!t.image && t.type === 1) + .map((t: Template) => ({ ...t, source: 'custom' })); } this.lastFetchTime = now; diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 0fc6e1b9..fbd304b5 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; @@ -31,6 +31,7 @@ export interface Template { docs_url?: string; architectures?: string[]; stars?: number; + source?: string; } interface AppStoreViewProps { @@ -48,6 +49,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { const [isDeploying, setIsDeploying] = useState(false); const [loading, setLoading] = useState(true); + const [selectedCategory, setSelectedCategory] = useState('All'); const [imgErrors, setImgErrors] = useState>({}); const [portVars, setPortVars] = useState>({}); const [isDescExpanded, setIsDescExpanded] = useState(false); @@ -180,11 +182,21 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { } }; - const filtered = templates.filter(t => - t.title.toLowerCase().includes(searchQuery.toLowerCase()) || - t.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (t.categories && t.categories.join(' ').toLowerCase().includes(searchQuery.toLowerCase())) - ); + const categories = useMemo(() => { + const cats = new Set(); + templates.forEach(t => t.categories?.forEach(c => cats.add(c))); + return ['All', ...Array.from(cats).sort()]; + }, [templates]); + + const filtered = useMemo(() => templates.filter(t => { + const matchesCategory = selectedCategory === 'All' || t.categories?.includes(selectedCategory); + const q = searchQuery.toLowerCase(); + const matchesSearch = !q || + t.title.toLowerCase().includes(q) || + t.description?.toLowerCase().includes(q) || + (t.categories && t.categories.join(' ').toLowerCase().includes(q)); + return matchesCategory && matchesSearch; + }), [templates, selectedCategory, searchQuery]); return (
@@ -196,11 +208,32 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { placeholder="Search App Store..." className="pl-8" value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} />
+ {!loading && categories.length > 1 && ( +
+
+ {categories.map(cat => ( + + ))} +
+ + {filtered.length} app{filtered.length !== 1 ? 's' : ''} + +
+ )} +
{loading ? (
@@ -233,7 +266,12 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
{t.categories.slice(0, 3).map(c => ( - + { e.stopPropagation(); setSelectedCategory(c); }} + > {c} ))} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 9f0bbd1e..3d0537be 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -12,7 +12,7 @@ import { Slider } from '@/components/ui/slider'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server } from 'lucide-react'; +import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw } from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; @@ -32,11 +32,11 @@ interface SettingsModalProps { export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) { const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; - const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes'>('account'); + const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'>('account'); // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes')) { + if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -63,6 +63,8 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se }); const [isLoading, setIsLoading] = useState(false); + const [registryUrl, setRegistryUrl] = useState(''); + const [isSavingRegistry, setIsSavingRegistry] = useState(false); useEffect(() => { if (isOpen) { @@ -93,12 +95,32 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se if (res.ok) { const data = await res.json(); setSettings(prev => ({ ...prev, ...data })); + if (data.template_registry_url) { + setRegistryUrl(data.template_registry_url); + } } } catch (e) { console.error('Failed to fetch settings', e); } }; + const saveRegistrySettings = async () => { + setIsSavingRegistry(true); + try { + await apiFetch('/settings', { + method: 'POST', + body: JSON.stringify({ key: 'template_registry_url', value: registryUrl.trim() }) + }); + // Bust the template cache so the next App Store load uses the new URL + await apiFetch('/templates/refresh-cache', { method: 'POST' }); + toast.success('Registry saved. App Store will reload from the new source.'); + } catch (e) { + toast.error('Failed to save registry settings.'); + } finally { + setIsSavingRegistry(false); + } + }; + const handleAgentChange = (type: string, field: keyof Agent, value: any) => { setAgents(prev => ({ ...prev, @@ -297,6 +319,16 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se Nodes )} + {!isRemote && ( + + )}
@@ -513,6 +545,62 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se )} + {activeSection === 'appstore' && ( +
+
+

App Store Registry

+

Configure the template source used by the App Store.

+
+ +
+
+
+ +

+ LinuxServer.io — https://api.linuxserver.io/api/v1/images +

+

Used when no custom registry is set.

+
+
+ +
+
+ +

+ Provide a URL pointing to a Portainer v2 compatible template JSON file. Overrides the default registry. +

+
+ setRegistryUrl(e.target.value)} + /> +

+ Leave empty to use the default LinuxServer.io registry. +

+
+
+ +
+ + +
+
+ )} +
From 322e7175140b1b11db79854d43c322fe2b860b93 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 19:57:34 -0400 Subject: [PATCH 07/68] feat(settings): harden settings API and overhaul SettingsModal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Strip auth credential keys (auth_username, auth_password_hash, auth_jwt_secret) from GET /api/settings response - Add allowlist guard to POST /api/settings — rejects unknown or auth-namespace keys with a 400 Backend: - Add PATCH /api/settings bulk endpoint with Zod schema validation (type coercion, range checks, URL format) and atomic SQLite transaction - Add system_state table — moves last_janitor_alert_timestamp out of global_settings; adds getSystemState/setSystemState on DatabaseService - Add metrics_retention_hours and log_retention_days configurable settings; MonitorService reads both dynamically each evaluation cycle - Add cleanupOldNotifications(days) to DatabaseService, called each cycle Frontend: - Replace single isLoading flag with per-operation states (isSavingSystem, isSavingDeveloper, isSavingPassword, isSavingRegistry, isSavingAgent/isTestingAgent per agent type) - Add skeleton loader that blocks interaction until fetchSettings resolves - Explicit key-picking in fetchSettings — auth keys cannot enter state - Unsaved-changes amber dot on System Limits and Developer sidebar items - Separate saveSystemSettings / saveDeveloperSettings — no cross-tab clobber - Developer tab gains Data Retention section (metrics hours, log days) - All settings saves use new PATCH /api/settings endpoint --- CHANGELOG.md | 6 + backend/package-lock.json | 12 +- backend/package.json | 3 +- backend/src/index.ts | 69 +- backend/src/services/DatabaseService.ts | 23 + backend/src/services/MonitorService.ts | 15 +- frontend/src/components/SettingsModal.tsx | 747 +++++++++++++--------- 7 files changed, 580 insertions(+), 295 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5cba386..17a77273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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] +- **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. +- **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. diff --git a/backend/package-lock.json b/backend/package-lock.json index 458df2a9..5b9e87f2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,7 +28,8 @@ "node-pty": "^1.1.0", "systeminformation": "^5.31.1", "ws": "^8.19.0", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -3134,6 +3135,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 1f7a0ffc..360c3c52 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,7 @@ "node-pty": "^1.1.0", "systeminformation": "^5.31.1", "ws": "^8.19.0", - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "^4.3.6" } } diff --git a/backend/src/index.ts b/backend/src/index.ts index afc8383c..507c0a66 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1246,11 +1246,48 @@ app.post('/api/agents', async (req: Request, res: Response) => { } }); +// 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) +const ALLOWED_SETTING_KEYS = new Set([ + 'host_cpu_limit', + 'host_ram_limit', + 'host_disk_limit', + 'docker_janitor_gb', + 'global_crash', + 'global_logs_refresh', + 'developer_mode', + 'template_registry_url', + 'metrics_retention_hours', + 'log_retention_days', +]); + +// 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), + host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String), + host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String), + docker_janitor_gb: z.coerce.number().min(0).transform(String), + global_crash: z.enum(['0', '1']), + global_logs_refresh: z.enum(['1', '3', '5', '10']), + developer_mode: z.enum(['0', '1']), + template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }), + metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String), + log_retention_days: z.coerce.number().int().min(1).max(365).transform(String), +}).partial(); + 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 + for (const key of PRIVATE_SETTINGS_KEYS) { + delete settings[key]; + } res.json(settings); } catch (error) { + console.error('Failed to fetch settings:', error); res.status(500).json({ error: 'Failed to fetch settings' }); } }); @@ -1258,13 +1295,43 @@ app.get('/api/settings', async (req: Request, res: Response) => { app.post('/api/settings', async (req: Request, res: Response) => { try { const { key, value } = req.body; - DatabaseService.getInstance().updateGlobalSetting(key, value); + if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) { + res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` }); + return; + } + if (value === undefined || value === null) { + res.status(400).json({ error: 'Setting value is required' }); + return; + } + DatabaseService.getInstance().updateGlobalSetting(key, String(value)); res.json({ success: true }); } catch (error) { + console.error('Failed to update setting:', error); res.status(500).json({ error: 'Failed to update setting' }); } }); +app.patch('/api/settings', async (req: Request, res: Response) => { + try { + const parsed = SettingsPatchSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors }); + return; + } + const db = DatabaseService.getInstance(); + const updateMany = db.getDb().transaction((entries: [string, string][]) => { + for (const [k, v] of entries) { + db.updateGlobalSetting(k, v); + } + }); + updateMany(Object.entries(parsed.data) as [string, string][]); + res.json({ success: true }); + } catch (error) { + console.error('Failed to bulk update settings:', error); + res.status(500).json({ error: 'Failed to update settings' }); + } +}); + app.get('/api/alerts', async (req: Request, res: Response) => { try { let stackName = req.query.stackName as string | undefined; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 7125931f..1cd01a9f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -136,6 +136,11 @@ export class DatabaseService { status TEXT NOT NULL DEFAULT 'unknown', created_at INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS system_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); `); // Apply migrations safely (ignore if columns already exist) @@ -167,6 +172,8 @@ export class DatabaseService { stmt.run('docker_janitor_gb', '5'); stmt.run('global_logs_refresh', '5'); stmt.run('developer_mode', '0'); + stmt.run('metrics_retention_hours', '24'); + stmt.run('log_retention_days', '30'); // Seed the default local node if none exists const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; @@ -244,6 +251,17 @@ export class DatabaseService { stmt.run(key, value); } + // --- 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; + return row?.value ?? null; + } + + public setSystemState(key: string, value: string): void { + this.db.prepare('INSERT OR REPLACE INTO system_state (key, value) VALUES (?, ?)').run(key, value); + } + // --- Stack Alerts --- public getStackAlerts(stackName?: string): StackAlert[] { @@ -353,6 +371,11 @@ export class DatabaseService { stmt.run(cutoff); } + public cleanupOldNotifications(daysToKeep = 30): void { + const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000); + this.db.prepare('DELETE FROM notification_history WHERE timestamp < ?').run(cutoff); + } + // --- Nodes --- public getNodes(): Node[] { diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 3881a2c5..4a09b252 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -187,13 +187,14 @@ export class MonitorService { // Only trigger once every while? To avoid spamming, we just check if it's over limit // Let's ensure we only spam once per limit breach. We can use a local static variable. const LAST_JANITOR_ALERT_KEY = 'last_janitor_alert_timestamp'; - const lastAlert = parseInt(settings[LAST_JANITOR_ALERT_KEY] || '0', 10); + const lastAlertRaw = DatabaseService.getInstance().getSystemState(LAST_JANITOR_ALERT_KEY); + const lastAlert = parseInt(lastAlertRaw || '0', 10); const janitorCooldown = 24 * 60 * 60 * 1000; // 24 hours cooldown for janitor if (reclaimGb >= janitorLimitGb) { if (Date.now() - lastAlert > janitorCooldown) { await notifier.dispatchAlert('info', `Your system has accumulated ${reclaimGb.toFixed(1)} GB of unused Docker data. Consider using the Janitor tool.`); - DatabaseService.getInstance().updateGlobalSetting(LAST_JANITOR_ALERT_KEY, Date.now().toString()); + DatabaseService.getInstance().setSystemState(LAST_JANITOR_ALERT_KEY, Date.now().toString()); } } } @@ -298,8 +299,14 @@ export class MonitorService { } try { - db.cleanupOldMetrics(24); - } catch (e) { } + const settings = db.getGlobalSettings(); + const retentionHours = parseInt(settings['metrics_retention_hours'] || '24', 10); + db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours); + const retentionDays = parseInt(settings['log_retention_days'] || '30', 10); + db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays); + } catch (e) { + console.error('MonitorService: failed to cleanup old data', e); + } } private evaluateCondition(actual: number, operator: string, threshold: number): boolean { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 3d0537be..92c80b2a 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Dialog, DialogContent, @@ -9,10 +9,12 @@ import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw } from 'lucide-react'; +import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; @@ -22,6 +24,22 @@ interface Agent { enabled: boolean; } +// Keys that the settings PATCH endpoint accepts +interface PatchableSettings { + host_cpu_limit?: string; + host_ram_limit?: string; + host_disk_limit?: string; + docker_janitor_gb?: string; + global_crash?: '0' | '1'; + global_logs_refresh?: '1' | '3' | '5' | '10'; + developer_mode?: '0' | '1'; + template_registry_url?: string; + metrics_retention_hours?: string; + log_retention_days?: string; +} + +type SectionId = 'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'; + interface SettingsModalProps { isOpen: boolean; onClose: () => void; @@ -29,10 +47,23 @@ interface SettingsModalProps { setIsDarkMode: (mode: boolean) => void; } +const DEFAULT_SETTINGS: PatchableSettings = { + host_cpu_limit: '90', + host_ram_limit: '90', + host_disk_limit: '90', + global_crash: '1', + docker_janitor_gb: '5', + global_logs_refresh: '5', + developer_mode: '0', + template_registry_url: '', + metrics_retention_hours: '24', + log_retention_days: '30', +}; + export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) { const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; - const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'>('account'); + const [activeSection, setActiveSection] = useState('account'); // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { @@ -44,45 +75,59 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se // Auth State const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' }); - // Notifications State + // Notification agents state const [agents, setAgents] = useState>({ discord: { type: 'discord', url: '', enabled: false }, slack: { type: 'slack', url: '', enabled: false }, webhook: { type: 'webhook', url: '', enabled: false }, }); - // System Settings State - const [settings, setSettings] = useState>({ - host_cpu_limit: '90', - host_ram_limit: '90', - host_disk_limit: '90', - global_crash: '1', - docker_janitor_gb: '5', - global_logs_refresh: '5', - developer_mode: '0' - }); + // Settings state — all user-configurable keys (no auth keys) + const [settings, setSettings] = useState({ ...DEFAULT_SETTINGS }); - const [isLoading, setIsLoading] = useState(false); - const [registryUrl, setRegistryUrl] = useState(''); + // Track server state to detect unsaved changes without causing re-renders + const serverSettingsRef = useRef({ ...DEFAULT_SETTINGS }); + + // Per-operation loading states + const [isSettingsLoading, setIsSettingsLoading] = useState(false); + const [isSavingSystem, setIsSavingSystem] = useState(false); + const [isSavingDeveloper, setIsSavingDeveloper] = useState(false); + const [isSavingPassword, setIsSavingPassword] = useState(false); const [isSavingRegistry, setIsSavingRegistry] = useState(false); + const [isSavingAgent, setIsSavingAgent] = useState>({}); + const [isTestingAgent, setIsTestingAgent] = useState>({}); + + // Unsaved changes indicators per section (compared against server ref) + const hasSystemChanges = + settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit || + settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit || + settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit || + settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb || + settings.global_crash !== serverSettingsRef.current.global_crash; + + const hasDeveloperChanges = + settings.developer_mode !== serverSettingsRef.current.developer_mode || + settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh || + settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours || + settings.log_retention_days !== serverSettingsRef.current.log_retention_days; useEffect(() => { if (isOpen) { fetchAgents(); fetchSettings(); } - }, [isOpen]); + }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps const fetchAgents = async () => { try { const res = await apiFetch('/agents'); if (res.ok) { const data: Agent[] = await res.json(); - const newAgents = { ...agents }; - data.forEach(a => { - newAgents[a.type] = a; + setAgents(prev => { + const next = { ...prev }; + data.forEach(a => { next[a.type] = a; }); + return next; }); - setAgents(newAgents); } } catch (e) { console.error('Failed to fetch agents', e); @@ -90,64 +135,127 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se }; const fetchSettings = async () => { + setIsSettingsLoading(true); try { const res = await apiFetch('/settings'); if (res.ok) { - const data = await res.json(); - setSettings(prev => ({ ...prev, ...data })); - if (data.template_registry_url) { - setRegistryUrl(data.template_registry_url); - } + const data: Record = await res.json(); + // Explicitly pick only known patchable keys — never allow auth keys into component state + const safe: PatchableSettings = { + host_cpu_limit: data.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit, + host_ram_limit: data.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit, + host_disk_limit: data.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit, + docker_janitor_gb: data.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb, + global_crash: (data.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash, + global_logs_refresh: (data.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh, + developer_mode: (data.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode, + template_registry_url: data.template_registry_url ?? '', + metrics_retention_hours: data.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours, + log_retention_days: data.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days, + }; + setSettings(safe); + serverSettingsRef.current = { ...safe }; } } catch (e) { console.error('Failed to fetch settings', e); + } finally { + setIsSettingsLoading(false); } }; + const handleSettingChange = (key: K, value: PatchableSettings[K]) => { + setSettings(prev => ({ ...prev, [key]: value })); + }; + + const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void): Promise => { + setLoading(true); + try { + const res = await apiFetch('/settings', { + method: 'PATCH', + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to save settings.'); + return false; + } + serverSettingsRef.current = { ...serverSettingsRef.current, ...payload }; + return true; + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + return false; + } finally { + setLoading(false); + } + }; + + const saveSystemSettings = async () => { + const ok = await patchSettings({ + host_cpu_limit: settings.host_cpu_limit, + host_ram_limit: settings.host_ram_limit, + host_disk_limit: settings.host_disk_limit, + docker_janitor_gb: settings.docker_janitor_gb, + global_crash: settings.global_crash, + }, setIsSavingSystem); + if (ok) toast.success('System limits saved.'); + }; + + const saveDeveloperSettings = async () => { + const ok = await patchSettings({ + developer_mode: settings.developer_mode, + global_logs_refresh: settings.global_logs_refresh, + metrics_retention_hours: settings.metrics_retention_hours, + log_retention_days: settings.log_retention_days, + }, setIsSavingDeveloper); + if (ok) toast.success('Developer settings saved.'); + }; + const saveRegistrySettings = async () => { setIsSavingRegistry(true); try { - await apiFetch('/settings', { - method: 'POST', - body: JSON.stringify({ key: 'template_registry_url', value: registryUrl.trim() }) + const res = await apiFetch('/settings', { + method: 'PATCH', + body: JSON.stringify({ template_registry_url: settings.template_registry_url ?? '' }), }); - // Bust the template cache so the next App Store load uses the new URL + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to save registry settings.'); + return; + } + serverSettingsRef.current = { ...serverSettingsRef.current, template_registry_url: settings.template_registry_url }; await apiFetch('/templates/refresh-cache', { method: 'POST' }); toast.success('Registry saved. App Store will reload from the new source.'); - } catch (e) { - toast.error('Failed to save registry settings.'); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Failed to save registry settings.'); } finally { setIsSavingRegistry(false); } }; - const handleAgentChange = (type: string, field: keyof Agent, value: any) => { + const handleAgentChange = (type: string, field: keyof Agent, value: Agent[keyof Agent]) => { setAgents(prev => ({ ...prev, [type]: { ...prev[type], [field]: value } })); }; - const handleSettingChange = (key: string, value: string) => { - setSettings(prev => ({ ...prev, [key]: value })); - }; - const saveAgent = async (type: string) => { - setIsLoading(true); + setIsSavingAgent(prev => ({ ...prev, [type]: true })); try { const res = await apiFetch('/agents', { method: 'POST', body: JSON.stringify(agents[type]) }); if (res.ok) { - toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved successfully.`); + toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved.`); } else { - toast.error(`Failed to save ${type} settings.`); + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Something went wrong.'); } - } catch (e) { - toast.error('Network error.'); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Network error.'); } finally { - setIsLoading(false); + setIsSavingAgent(prev => ({ ...prev, [type]: false })); } }; @@ -156,7 +264,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se toast.error('Please enter a webhook URL first.'); return; } - setIsLoading(true); + setIsTestingAgent(prev => ({ ...prev, [type]: true })); try { const res = await apiFetch('/notifications/test', { method: 'POST', @@ -165,64 +273,46 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se if (res.ok) { toast.success('Test notification sent!'); } else { - const err = await res.json(); - toast.error(err.details || 'Test failed.'); + const err = await res.json().catch(() => ({})); + toast.error(err?.details || err?.error || 'Test failed.'); } - } catch (e) { - toast.error('Network error.'); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Network error.'); } finally { - setIsLoading(false); - } - }; - - const saveSettings = async () => { - setIsLoading(true); - try { - for (const [key, value] of Object.entries(settings)) { - await apiFetch('/settings', { - method: 'POST', - body: JSON.stringify({ key, value }) - }); - } - toast.success('System limits & watchdog settings saved.'); - } catch (e) { - toast.error('Failed to save settings.'); - } finally { - setIsLoading(false); + setIsTestingAgent(prev => ({ ...prev, [type]: false })); } }; const handlePasswordChange = async () => { if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) { - toast.error("All fields are required"); + toast.error('All fields are required'); return; } if (authData.newPassword !== authData.confirmPassword) { - toast.error("New passwords do not match"); + toast.error('New passwords do not match'); return; } - - setIsLoading(true); + if (authData.newPassword.length < 6) { + toast.error('New password must be at least 6 characters'); + return; + } + setIsSavingPassword(true); try { const res = await apiFetch('/auth/password', { method: 'PUT', - body: JSON.stringify({ - oldPassword: authData.oldPassword, - newPassword: authData.newPassword - }) + body: JSON.stringify({ oldPassword: authData.oldPassword, newPassword: authData.newPassword }) }); - if (res.ok) { toast.success('Password updated successfully'); setAuthData({ oldPassword: '', newPassword: '', confirmPassword: '' }); } else { - const data = await res.json(); - toast.error(data.error || 'Failed to update password'); + const data = await res.json().catch(() => ({})); + toast.error(data?.error || 'Failed to update password'); } - } catch (e) { - toast.error('Network error during password change'); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Network error during password change'); } finally { - setIsLoading(false); + setIsSavingPassword(false); } }; @@ -246,94 +336,87 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se />
- - + +
); + const SettingsSkeleton = () => ( +
+ + +
+ + + +
+
+ ); + + const NavButton = ({ section, icon, label, showDot }: { section: SectionId; icon: React.ReactNode; label: string; showDot?: boolean }) => ( + + ); + return ( !open && onClose()}> {/* Sidebar */}
Settings Hub
- {isRemote && ( + {isRemote ? (
{activeNode!.name}
+ ) : ( +
)} - {!isRemote &&
}
{/* Main Content Area */}
+ {activeSection === 'account' && (
@@ -365,8 +448,11 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se onChange={(e) => setAuthData(prev => ({ ...prev, confirmPassword: e.target.value }))} />
-
@@ -374,74 +460,97 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se {activeSection === 'system' && (
-
-

System Limits & Watchdog

-

Configure auto-recovery thresholds and server constraints.

+
+
+

System Limits & Watchdog

+

Configure alert thresholds and crash detection.

+
+ {isRemote && ( + + + {activeNode!.name} + + )}
-
-
-
- - {settings.host_cpu_limit}% + {isSettingsLoading ? : ( + <> +
+
+
+ + {settings.host_cpu_limit}% +
+ handleSettingChange('host_cpu_limit', v[0].toString())} + /> +
+ +
+
+ + {settings.host_ram_limit}% +
+ handleSettingChange('host_ram_limit', v[0].toString())} + /> +
+ +
+
+ + {settings.host_disk_limit}% +
+ handleSettingChange('host_disk_limit', v[0].toString())} + /> +
+ +
+ +
+ handleSettingChange('docker_janitor_gb', e.target.value)} + className="max-w-[150px]" + /> + GB reclaimable +
+

Alert when unused Docker data exceeds this size.

+
+ +
+
+ +

Watch all containers for unexpected exits

+
+ handleSettingChange('global_crash', c ? '1' : '0')} + /> +
- handleSettingChange('host_cpu_limit', v[0].toString())} - /> -
-
-
- - {settings.host_ram_limit}% +
+
- handleSettingChange('host_ram_limit', v[0].toString())} - /> -
- -
-
- - {settings.host_disk_limit}% -
- handleSettingChange('host_disk_limit', v[0].toString())} - /> -
- -
- - handleSettingChange('docker_janitor_gb', e.target.value)} - className="max-w-[200px]" - /> -
- -
-
- -

Watch all containers indefinitely

-
- handleSettingChange('global_crash', c ? '1' : '0')} - /> -
-
- -
- -
+ + )}
)} @@ -470,7 +579,6 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se

Appearance

Customize Sencho's visual theme.

-
-
+
+
+ +

How long to keep alert and notification history.

+
+
+ handleSettingChange('log_retention_days', e.target.value)} + className="w-20" + /> + days +
+
+
+
+ +
+ +
+ + )}
)} @@ -552,52 +724,51 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se

Configure the template source used by the App Store.

-
-
-
- -

- LinuxServer.io — https://api.linuxserver.io/api/v1/images -

-

Used when no custom registry is set.

-
-
+ {isSettingsLoading ? : ( + <> +
+
+ +

+ LinuxServer.io — https://api.linuxserver.io/api/v1/images +

+

Used when no custom registry is set.

+
-
-
- -

- Provide a URL pointing to a Portainer v2 compatible template JSON file. Overrides the default registry. -

+
+
+ +

+ Provide a URL pointing to a Portainer v2 compatible template JSON file. Overrides the default registry. +

+
+ handleSettingChange('template_registry_url', e.target.value)} + /> +

Leave empty to use the default LinuxServer.io registry.

+
- setRegistryUrl(e.target.value)} - /> -

- Leave empty to use the default LinuxServer.io registry. -

-
-
-
- - -
+
+ + +
+ + )}
)} From f7e8e409158e038404d4a531331b09d7c37f5113 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 20:12:00 -0400 Subject: [PATCH 08/68] =?UTF-8?q?feat(settings):=20scope=20split=20?= =?UTF-8?q?=E2=80=94=20developer=20settings=20always=20target=20local=20no?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add localOnly option to apiFetch — omits x-node-id header so the request bypasses the proxy and always hits the local Sencho instance - fetchSettings now performs two fetches when a remote node is active: active node fetch for per-node settings (CPU/RAM/disk limits, janitor, crash detection), and a localOnly fetch for UI preferences (developer_mode, global_logs_refresh, metrics_retention_hours, log_retention_days) - saveDeveloperSettings passes localOnly: true — developer preferences can no longer be written into a remote node's database - Update scope badges: System Limits shows "Configuring: [node]", Developer shows "Always Local" when a remote node is active --- CHANGELOG.md | 3 ++ frontend/src/components/SettingsModal.tsx | 58 +++++++++++++---------- frontend/src/lib/api.ts | 17 +++++-- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a77273..74d18386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 92c80b2a..464de5c4 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -137,25 +137,33 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se const fetchSettings = async () => { setIsSettingsLoading(true); try { - const res = await apiFetch('/settings'); - if (res.ok) { - const data: Record = await res.json(); - // Explicitly pick only known patchable keys — never allow auth keys into component state - const safe: PatchableSettings = { - host_cpu_limit: data.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit, - host_ram_limit: data.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit, - host_disk_limit: data.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit, - docker_janitor_gb: data.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb, - global_crash: (data.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash, - global_logs_refresh: (data.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh, - developer_mode: (data.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode, - template_registry_url: data.template_registry_url ?? '', - metrics_retention_hours: data.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours, - log_retention_days: data.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days, - }; - setSettings(safe); - serverSettingsRef.current = { ...safe }; - } + // Fetch per-node settings from the active node (system limits etc.) + const nodeRes = await apiFetch('/settings'); + // Always fetch developer/UI preferences from local — these control + // this Sencho instance's behaviour and must never be proxied to remote + const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes; + + const nodeData: Record = nodeRes.ok ? await nodeRes.json() : {}; + const localData: Record = (isRemote && localRes.ok) + ? await localRes.json() + : nodeData; + + const safe: PatchableSettings = { + // Per-node: read from active node + host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit, + host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit, + host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit, + docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb, + global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash, + template_registry_url: nodeData.template_registry_url ?? '', + // Local-only: always read from local node + global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh, + developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode, + metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours, + log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days, + }; + setSettings(safe); + serverSettingsRef.current = { ...safe }; } catch (e) { console.error('Failed to fetch settings', e); } finally { @@ -167,12 +175,13 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se setSettings(prev => ({ ...prev, [key]: value })); }; - const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void): Promise => { + const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void, localOnly = false): Promise => { setLoading(true); try { const res = await apiFetch('/settings', { method: 'PATCH', body: JSON.stringify(payload), + localOnly, }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -201,12 +210,13 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se }; const saveDeveloperSettings = async () => { + // Developer/UI preferences are local-only — never proxy to remote node const ok = await patchSettings({ developer_mode: settings.developer_mode, global_logs_refresh: settings.global_logs_refresh, metrics_retention_hours: settings.metrics_retention_hours, log_retention_days: settings.log_retention_days, - }, setIsSavingDeveloper); + }, setIsSavingDeveloper, true); if (ok) toast.success('Developer settings saved.'); }; @@ -468,7 +478,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se {isRemote && ( - {activeNode!.name} + Configuring: {activeNode!.name} )}
@@ -608,9 +618,9 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se

Power user settings for real-time observability and data retention.

{isRemote && ( - + - {activeNode!.name} + Always Local )} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7059763f..da854367 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,22 +1,29 @@ const API_BASE = '/api'; +export interface ApiFetchOptions extends RequestInit { + /** When true, omits the x-node-id header so the request always targets + * the local node regardless of which node is currently active in the UI. */ + localOnly?: boolean; +} + export async function apiFetch( endpoint: string, - options: RequestInit = {} + options: ApiFetchOptions = {} ): Promise { + const { localOnly, ...fetchOptions } = options; const url = `${API_BASE}${endpoint}`; - const activeNodeId = localStorage.getItem('sencho-active-node'); - + const activeNodeId = localOnly ? null : localStorage.getItem('sencho-active-node'); + const defaultOptions: RequestInit = { credentials: 'include', headers: { 'Content-Type': 'application/json', ...(activeNodeId ? { 'x-node-id': activeNodeId } : {}), - ...options.headers, + ...fetchOptions.headers, }, }; - const response = await fetch(url, { ...defaultOptions, ...options }); + const response = await fetch(url, { ...defaultOptions, ...fetchOptions }); if (response.status === 401) { // Signal auth failure to AuthContext without a hard page reload From ed0817b2c59187b3a1ac9dc52a9ce6ec6e3427bd Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 20:16:52 -0400 Subject: [PATCH 09/68] fix(settings): prevent X button overlap and add tooltip to Always Local badge --- frontend/src/components/SettingsModal.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 464de5c4..f67d68f9 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -15,6 +15,7 @@ import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; @@ -470,7 +471,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se {activeSection === 'system' && (
-
+

System Limits & Watchdog

Configure alert thresholds and crash detection.

@@ -612,16 +613,25 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se {activeSection === 'developer' && (
-
+

Developer

Power user settings for real-time observability and data retention.

{isRemote && ( - - - Always Local - + + + + + + Always Local + + + + These settings control this Sencho instance's UI behaviour and are never synced to remote nodes. + + + )}
From a5ac3e4981383eda70f4817c530e89872c3639b6 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 20:47:51 -0400 Subject: [PATCH 10/68] feat(notifications): replace polling with WebSocket push Eliminates the 5-second setInterval polling loop for notifications in EditorLayout. A persistent /ws/notifications WebSocket connection is opened on mount; the backend pushes each alert the instant dispatchAlert fires, with 5s auto-reconnect on close. Key changes: - NotificationService: injectable broadcaster callback (setBroadcaster) - DatabaseService.addNotificationHistory: returns full NotificationHistory record (with id/is_read) instead of void - index.ts: notificationSubscribers Set + /ws/notifications upgrade handler (JWT-verified, placed before remote proxy path) - EditorLayout: polling removed, WS connect/reconnect replaces it --- CHANGELOG.md | 4 ++ backend/src/index.ts | 23 +++++++++++ backend/src/services/DatabaseService.ts | 12 +++++- backend/src/services/NotificationService.ts | 17 ++++++-- frontend/src/components/EditorLayout.tsx | 43 +++++++++++++++++++-- 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d18386..406976e6 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] +- **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. diff --git a/backend/src/index.ts b/backend/src/index.ts index 507c0a66..068b29d5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -418,6 +418,17 @@ const wss = new WebSocket.Server({ noServer: true }); let terminalWs: WebSocket | null = null; +// Notification push — set of authenticated browser clients subscribed to real-time alerts +const notificationSubscribers = new Set(); +NotificationService.getInstance().setBroadcaster((notification) => { + const msg = JSON.stringify({ type: 'notification', payload: notification }); + for (const ws of notificationSubscribers) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } + } +}); + // Handle WebSocket upgrade with JWT authentication server.on('upgrade', async (req, socket, head) => { // Parse cookies from the upgrade request @@ -450,6 +461,18 @@ 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 + if (pathname === '/ws/notifications') { + const notifWss = new WebSocket.Server({ noServer: true }); + notifWss.handleUpgrade(req, socket, head, (ws) => { + notifWss.close(); + notificationSubscribers.add(ws); + ws.on('close', () => notificationSubscribers.delete(ws)); + ws.on('error', () => { notificationSubscribers.delete(ws); ws.terminate(); }); + }); + return; + } + // Resolve node context from query param const nodeIdParam = parsedUrl.searchParams.get('nodeId'); const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 1cd01a9f..4e703d10 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -310,9 +310,9 @@ export class DatabaseService { })); } - public addNotificationHistory(notification: Omit): void { + public addNotificationHistory(notification: Omit): NotificationHistory { const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)'); - stmt.run(notification.level, notification.message, notification.timestamp); + const result = stmt.run(notification.level, notification.message, notification.timestamp); this.db.exec(` DELETE FROM notification_history @@ -320,6 +320,14 @@ export class DatabaseService { SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100 ) `); + + return { + id: result.lastInsertRowid as number, + level: notification.level, + message: notification.message, + timestamp: notification.timestamp, + is_read: false, + }; } public markAllNotificationsRead(): void { diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index ebcf0033..e42ab469 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -1,8 +1,9 @@ -import { DatabaseService } from './DatabaseService'; +import { DatabaseService, NotificationHistory } from './DatabaseService'; export class NotificationService { private static instance: NotificationService; private dbService: DatabaseService; + private broadcaster: ((notification: NotificationHistory) => void) | null = null; private constructor() { this.dbService = DatabaseService.getInstance(); @@ -15,14 +16,24 @@ export class NotificationService { return NotificationService.instance; } + /** Wire up the WebSocket push function after the WS server is initialised. */ + public setBroadcaster(fn: (notification: NotificationHistory) => void): void { + this.broadcaster = fn; + } + public async dispatchAlert(level: 'info' | 'warning' | 'error', message: string) { - // 1. Log to history - this.dbService.addNotificationHistory({ + // 1. Log to history and get the full inserted record (with id) + const notification = this.dbService.addNotificationHistory({ level, message, timestamp: Date.now() }); + // 2. Push to connected browser clients via WebSocket + if (this.broadcaster) { + this.broadcaster(notification); + } + // 2. Fetch enabled agents const agents = this.dbService.getEnabledAgents(); if (agents.length === 0) { diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 6f25632b..bcd0e1d1 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -162,11 +162,48 @@ export default function EditorLayout() { } }; - // Notification polling - independent of active node, runs once on mount + // Notification WS push — load history once on mount, then receive live updates useEffect(() => { fetchNotifications(); - const notificationInterval = setInterval(fetchNotifications, 5000); - return () => clearInterval(notificationInterval); + + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsBase = `${wsProtocol}//${window.location.host}`; + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let isMounted = true; + + const connect = () => { + ws = new WebSocket(`${wsBase}/ws/notifications`); + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'notification' && msg.payload) { + setNotifications(prev => [msg.payload, ...prev]); + } + } catch (e) { + console.error('[WS notifications] parse error', e); + } + }; + + ws.onclose = () => { + if (isMounted) { + reconnectTimer = setTimeout(connect, 5000); + } + }; + + ws.onerror = () => { + ws?.close(); + }; + }; + + connect(); + + return () => { + isMounted = false; + if (reconnectTimer) clearTimeout(reconnectTimer); + ws?.close(); + }; }, []); // eslint-disable-line react-hooks/exhaustive-deps // Re-fetch stacks whenever the active node changes (or becomes available on mount). From 0cb5fae947ac69b5e3b6c06c82d2f2870e6600de Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 22:25:29 -0400 Subject: [PATCH 11/68] 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 + + +