- Add monaco-editor to frontend deps and configure @monaco-editor/react
to use the local bundle via loader.config({ monaco }) instead of
fetching from cdn.jsdelivr.net (blocked by CSP scriptSrc: self)
- Wire editorWorker via Vite ?worker syntax — blob: URLs already
permitted by existing workerSrc CSP directive, no CSP changes needed
- fix(ci): use DOCS_REPO_TOKEN in release-please so tag creation
cascades to docker-publish.yml (GITHUB_TOKEN cannot trigger workflows)
- fix(ci): auto-merge screenshots PR via gh pr merge --auto --squash
Two console errors on HTTP deployments with no functional impact:
1. Helmet's default Cross-Origin-Opener-Policy: same-origin is ignored
by browsers over HTTP but logged as a console error. Disabled via
crossOriginOpenerPolicy: false (same rationale as HSTS/COEP).
2. Vite's production build injects an inline module-preload polyfill
script blocked by script-src 'self'. Disabled via
build.modulePreload.polyfill: false — all modern browsers support
link rel="modulepreload" natively.
- Add x-sencho-proxy sentinel header to all proxied responses so
the frontend can distinguish remote auth failures from local session
expiry, breaking the logout loop when a node's api_token expires
- Add authMiddleware to all 5 /api/notifications endpoints that were
missing protection (default-deny policy enforcement)
- Expand CSP to include connectSrc ws:/wss: and workerSrc blob:
for WebSocket and Monaco editor worker support
- Classify all Docker images, volumes, and networks as managed (Sencho
stack), external (other Compose project), or unused/system via a new
getClassifiedResources() method and GET /api/system/resources endpoint
- Add pruneManagedOnly() + getDiskUsageClassified() to DockerController
- Prune buttons now default to Sencho-managed scope; "All Docker" is
hidden in a ⋮ dropdown with a distinct destructive confirm dialog
- Replace Reclaimable Space donut with interactive Docker Disk Footprint
widget (stacked bar with clickable segments that filter resource tabs)
- Add managed/external filter toggles and classification badges per tab
- GET /api/stats now returns managed + unmanaged container counts; Home
Dashboard Active Containers card subtitle shows "N managed · N external"
- Rename "Ghost Containers" tab/copy to "Unmanaged Containers" throughout
- Replace catch (error: any) with catch (error) + (error as Error).message cast
in EditorLayout, NodeManager, HomeDashboard, AppStoreView
- Define TemplateVolume interface in AppStoreView; replace volumes any[] with typed array
- Define MetricPoint interface in HomeDashboard; replace metrics any[] with MetricPoint[]
- Define TerminalContainer type in BashExecModal; replace as any DOM property casts
- Define NodeTestInfo interface in NodeManager; replace info: any with typed shape
- Fix DockerNetworkStats cast in EditorLayout container stats WebSocket handler
- Remove unused catch variable (e) in api.ts and other components
- Cast streamFilter onValueChange val to union type in GlobalObservabilityView
- Add eslint-disable-next-line react-refresh/only-export-components to badge.tsx,
button.tsx, AuthContext, NodeContext, use-data-state, use-is-in-view
- Add eslint-disable-next-line react-hooks/set-state-in-effect in LogViewer
- Add /* eslint-disable */ to animate-ui third-party primitive files
- Fix rate limiter to allow 100 attempts in dev mode so E2E tests are
not blocked by failed-login attempts during test development
- Simplify logout button selector to use Lucide icon class (lucide-log-out)
instead of the fragile Tooltip-content locator chain that broke on navigation
- Rewrite stacks E2E spec: use waitForFunction to wait for sidebar to load,
delete leftover stacks via browser-context fetch before creating, and use
page.reload() to get a clean sidebar state
- Fix AlertDialogContent: remove asChild+motion.div pattern that triggered a
React.Children.only crash — Radix AlertDialog.Content injects a second
DescriptionWarning child internally, breaking Slot when asChild=true; replace
with CSS keyframe animations (data-[state=open]:animate-in)
- Fix final assertion in delete test to use exact text + listbox scope to avoid
false positives from similarly-named stacks like e2e-test-stack-*
- All 6 E2E tests pass (4 auth + 2 stacks); node tests skip gracefully
SECURITY (critical fixes):
- Add authMiddleware to /api/system/console-token (was publicly accessible)
- Validate api_url on node create/update to prevent SSRF (rejects localhost/loopback)
- Add rate limiting (5 req/15 min/IP) to /api/auth/login and /api/auth/setup
- Fix path traversal in env_file resolution — absolute/escaping paths rejected
- Add stack name validation to GET routes (was only on PUT/POST)
- Add helmet security headers middleware
- Restrict CORS to FRONTEND_URL in production
PRODUCTION READINESS:
- Add GET /api/health public endpoint + HEALTHCHECK in Dockerfile
- Add SIGTERM/SIGINT graceful shutdown handler (drains connections, closes DB)
- Run container as non-root sencho user in Dockerfile
QUALITY:
- Fix 4 silent empty catch{} blocks in EditorLayout (now show toast.error)
- Connect ErrorBoundary to root App in main.tsx
- Replace WebSocket.Server with named WebSocketServer import (ESM compat)
TESTING (new automated test suite):
- Install Vitest; 38 backend tests across 4 suites covering validation utilities,
health endpoint, auth middleware, login flows, SSRF protection, and path traversal
- Extract isValidStackName/isValidRemoteUrl/isPathWithinBase to utils/validation.ts
- Playwright E2E scaffolding: auth, stacks, nodes specs + shared login helper
- CI: run Vitest + ESLint on every PR
Remote-node alerts now appear in the local notification bell alongside
local ones, each tagged with the originating node name.
- backend: reorder WS upgrade handler so /ws/notifications?nodeId=<remote>
falls through to the existing proxy path instead of short-circuiting
- frontend/api.ts: add fetchForNode() helper for explicit node-targeted
requests without touching the localStorage active-node key
- frontend/EditorLayout: fetch notification history from all registered
nodes in parallel on mount and on node-list changes; open a per-remote
WebSocket connection for real-time push; route mark-read / delete / clear
actions back to the originating node; show node-name badge on remote alerts
- StackAlertSheet: fetch agent status on open and show contextual banner
(amber warning when no channels configured, green when active, blue info
callout on remote nodes explaining remote-instance evaluation semantics)
- StackAlertSheet: surface actual server error message on addAlert failure
instead of generic string; add console logging for all failure paths
- SettingsModal: expose Notifications tab for remote nodes so agents can be
configured directly on any Sencho instance; section header shows node name
and Remote badge with tooltip when proxying to a remote node
- SettingsModal: re-fetch agents/settings when active node changes
- SettingsModal: add hidden DialogTitle/DialogDescription to satisfy Radix
UI accessibility requirements (eliminates console errors)
- backend POST /api/alerts: add Zod validation schema; rejects unknown
metric/operator values, negative thresholds, and missing fields with 400
- EditorLayout WS: upgrade reconnect from flat 5s retry to exponential
backoff (1s→2s→4s→8s→16s→30s max); onerror now logs the event; cleanup
only closes OPEN sockets — CONNECTING sockets are closed in onopen after
isMounted check, eliminating "closed before established" StrictMode error
- fix(editor): Monaco compose↔.env tab switching no longer accumulates height
each switch — root cause was Monaco's position:static internal child creating
a circular CSS dependency with the CSS grid; broken by calling layout({0,0})
to force reflow before re-measuring. Added overflow-hidden to Monaco container.
- fix(ui): sliding tab indicator on compose/env tabs via motion.div layoutId
- fix(ui): sliding tab indicator on Notification tabs (Discord/Slack/Webhook)
- fix(ui): switch thumb now animates position (Radix data-attribute CSS translation)
- fix(ui): 'Always Local' tooltip no longer crashes — replaced animate-ui tooltip
(getStrictContext throws outside provider) with pure Radix primitives + CSS animation
- feat(settings): Auto theme option added (light/dark/auto with matchMedia listener)
- fix(ui): NodeManager dialog buttons no longer stuck together (DialogFooter gap)
- fix(ui): AlertDialog spring animation + Quick Clean button text wraps correctly
- fix(ui): Resources/App Store/Logs buttons toggle off on second click
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.
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
- 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
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
- 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.
Each container's onmessage handler was calling setContainerStats()
independently, causing React to schedule up to N separate reconciliation
passes per second (one per container). With 20 containers streaming Docker
stats at ~1 update/s, EditorLayout was re-rendering up to 20 times/s.
Fix: incoming stats are written into pendingStatsRef (no re-render cost),
then flushed to React state in one batched setContainerStats call every 1.5s.
Two bugs in the original proposal are addressed:
- rawBytesRef (never cleared) owns rx/tx tracking so net I/O rate is always
accurate; avoids the stale containerStats closure that would have shown
0 B/s after every flush cycle
- pending snapshot is captured and cleared BEFORE calling setState so the
functional updater stays pure (no side-effects inside it)
- pendingStatsRef is cleared in the effect cleanup so stale entries from
the previous stack don't briefly appear on stack switch
key={idx} with a shifting .slice(-300) window meant every new log arrival
caused React to update all 300 existing DOM nodes (index 1 became 0, 2
became 1, etc.), even though only one entry actually changed.
The proposed content-hash fix (timestamp + containerName + message.substring)
carries a real collision risk: chatty containers emitting identical lines at
the same millisecond produce duplicate keys, triggering undefined React
reconciliation behaviour.
Fix: add a _id: number field to LogEntry, stamped client-side with a
monotonic counter (logIdRef) at the point of ingestion — once in the SSE
onmessage handler and once in the polling setLogs path. React now tracks
data identity rather than position, so the slice window can shift without
touching the 299 unchanged DOM nodes.
Playwright investigation revealed the Logs view (GlobalObservabilityView)
rendered 1,859+ log entries as real DOM nodes (9,616 total DOM nodes) with
no virtualization. Combined with a 5-second polling cycle replacing all
React elements each time and smooth-scroll animations stacking on every
update, the renderer process grew rapidly on a host running at 97% RAM
usage, crashing the browser tab with Out of Memory within minutes.
Fixes:
- Cap rendered DOM rows to MAX_DISPLAY_ROWS (300) via .slice(-300) so the
browser only ever holds ~1,500 log-related DOM nodes regardless of how
many entries are in state
- Add a truncation notice when log count exceeds the display cap
- Reduce SSE-mode in-memory log cap from 10,000 to MAX_LOG_ENTRIES (2,000)
- Switch auto-scroll from behavior:'smooth' to behavior:'instant' to stop
stacking layout animations on every 5-second poll
- Reduce /api/logs/global response limit from 2,000 to 500 lines since the
client renders at most 300 rows, making the extra payload wasteful
- Generic WebSockets (stats, exec) now connect to /ws?nodeId= instead of
the bare root so the upgrade handler detects the remote node and proxies
the WS connection to the correct remote Sencho instance.
- Vite dev proxy gains ws:true on /api and a new /ws entry so WebSocket
upgrades reach localhost:3000 during npm run dev.
- Backend WS message handler falls back to the default local node when the
nodeId in a proxied message doesn't exist in the local DB.
- Open App button now extracts the hostname from the remote node's api_url
instead of using window.location.hostname.
Adds an ImageUpdateService that queries OCI registry manifest endpoints
every 6 hours to compare remote digests against local RepoDigests.
Results are cached in a new stack_update_status SQLite table. A pulsing
blue dot badge appears in the stack sidebar for stacks with updates
available. Manual refresh available via POST /api/image-updates/refresh
with a 10-minute rate limit.
Task 1 - Network layer fixes:
- AppStoreView: replace raw fetch() with apiFetch() on /templates and
/templates/deploy so x-node-id header is injected for proxy routing
- GlobalObservabilityView: replace raw fetch() with apiFetch() on
/stacks and /logs/global
- HostConsole: append ?nodeId= to WebSocket URL so the upgrade handler
correctly routes the PTY session to the active remote node
Task 2 - Scoped context two-tier UX (Option A / Portainer-style):
- EditorLayout: add a context pill in the top header bar showing the
active node name (pulsing blue for remote, green for local)
- HostConsole: header now reads "Host Console - [Node Name]"
- ResourcesView: title now reads "Resources Hub - [Node Name]" for remote
- GlobalObservabilityView: floating node badge in top-left corner for remote
- AppStoreView: deploy sheet shows "Deploying to: [Node Name]" badge
- SettingsModal: remote nodes only see System Limits + Developer tabs;
global-only tabs (Account, Appearance, Notifications, Nodes) are hidden;
header subtitle shows remote node name
- HomeDashboard: both polling useEffects now depend on activeNode?.id so
intervals restart and stale local-node data is cleared immediately on
node switch; added res.ok guard before calling res.json() to prevent
error objects being written into stats/systemStats state
- EditorLayout: refreshStacks now guards res.ok before parsing the JSON
body and iterates a typed fileList instead of the raw response value,
preventing SyntaxError/TypeError when proxy returns a non-JSON response
for an unreachable remote node
Add Node modal — type selector & state reset:
- Restored a Local/Remote <Select> dropdown in renderFormFields so users can
explicitly choose the node type instead of it defaulting silently to 'remote'.
- Switching type clears api_url and api_token so no stale remote credentials
carry over if a user switches from Remote to Local mid-form.
- Replaced the static "Add Remote Node" title with a dynamic one that reflects
the currently selected type ("Add Local Node" / "Add Remote Node").
- onOpenChange now resets formData to defaultFormData whenever the dialog
opens, preventing stale values from a previous session leaking in.
Remote connection details — real metrics:
- testRemoteConnection previously returned hard-coded '-' for containers,
images, and cpus after a successful auth/check ping.
- Now fires three parallel requests (Promise.allSettled) after auth passes:
/api/stats → containers total + running count
/api/system/stats → cpu.cores
/api/system/images → image list length
- Each field falls back to '-' gracefully if an endpoint is unavailable,
so a slow or older remote instance never breaks the connection test.
DEP0060 util._extend suppression:
- http-proxy@1.18.1 calls util._extend when createProxyServer() is first
invoked at runtime (NOT at import time). A process.emitWarning override
placed before the proxy instantiations intercepts only DEP0060 without
suppressing any other warnings. No package version changes needed.
Also includes linter/formatter normalisation across multiple files.
Proxy memory leak (MaxListenersExceededWarning + DEP0060):
createProxyMiddleware was instantiated inside the request handler on every
single API call. Each new instance registered fresh 'close' listeners on the
HTTP server and re-ran the http-proxy util._extend deprecated path. After ~10
requests the MaxListeners threshold was breached. Fix: declare ONE global
remoteNodeProxy at startup using the router option to dynamically resolve the
target URL per request. Listeners are registered once. ECONNREFUSED errors are
caught in the on.error handler and returned as structured 502 JSON.
Node switcher "nothing happens":
EditorLayout had a single useEffect([], []) that called refreshStacks() once
on mount. Changing the active node updated NodeContext state and localStorage
but nothing re-triggered the stack list fetch. Fix: split into two effects —
notifications polling (no dependency) and a stack-refresh effect keyed on
activeNode?.id. When the node changes, stale editor/container/file state is
cleared and the stacks for the new node are fetched.
Copy button silently failing:
navigator.clipboard.writeText() throws DOMException in non-HTTPS / non-localhost
contexts (e.g. http://192.168.x.x). The uncaught async exception silently
swallowed the success toast and state update. Fix: wrapped in try/catch with
an execCommand('copy') textarea fallback and a final error toast if both fail.
- Move NodeProvider inside the authenticated branch in App.tsx so it only
mounts after auth is confirmed; previously it mounted on boot causing
refreshNodes to fire before any session existed
- Replace window.location.href='/' on 401 in apiFetch with a
sencho-unauthorized custom event; AuthContext listens and transitions
appStatus to notAuthenticated without a full browser reload
- Cap GlobalObservabilityView SSE log array at 10,000 entries to prevent
unbounded memory growth during long dev-mode sessions
- Break NodeContext infinite re-fetch loop by replacing the activeNode
closure dependency in refreshNodes with a useRef read, making the
callback stable and preventing the useEffect -> setState -> useCallback
-> useEffect cycle
NodeContext.tsx was missing the three TLS fields that were already
present on the backend Node type, causing TS2339 build errors in
NodeManager.tsx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- NodeRegistry: wire TLS ca/cert/key into Dockerode when present on node config
- index.ts: /api/system/stats now branches on node type — remote nodes use docker.info() for CPU/RAM, local keeps systeminformation; disk gracefully returns null for remote
- index.ts: POST /api/nodes now persists tls_ca, tls_cert, tls_key fields
- FileSystemService: throw clean error on missing/empty compose_dir instead of crashing path.join
- FileSystemService: guard getStacks() against falsy item.name entries
- SSHFileAdapter: filter undefined/non-string names from SFTP readdir before returning
- NodeManager: add SSH Authentication Type toggle (Password vs Private Key)
- NodeManager: add Enable TLS toggle with conditional CA/cert/key textarea fields
- NodeManager: auto-test connection immediately after node creation
- NodeManager: replace "Strategy B" copy with Docker TCP setup instructions
- NodeManager: add pr-8 to header and DialogHeader to prevent overlap with parent dialog X button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>