Helmet's defaults are designed for HTTPS-only deployments. Two directives
were actively breaking plain-HTTP self-hosted instances:
- upgrade-insecure-requests: causes browsers to upgrade all JS/CSS/asset
fetches to HTTPS → ERR_SSL_PROTOCOL_ERROR → completely blank page
- Strict-Transport-Security: permanently instructs browsers to refuse HTTP
for 1 year, even after the header is removed from the server
Also handle docker socket GID=0 (root:root) edge case in entrypoint and
add [entrypoint] diagnostic log lines for easier debugging.
Add docker-entrypoint.sh that runs as root at startup, fixes ownership
of the DATA_DIR volume (only files with wrong user or group), then drops
to the non-root sencho user via su-exec before starting Node.
This eliminates the SQLITE_READONLY crash that occurs when a host-mounted
data volume was created by root or chowned to the wrong UID. The pattern
mirrors the official PostgreSQL, Redis, and MariaDB Docker images.
- Install su-exec in Stage 3 (10KB Alpine tool, idiomatic alternative to gosu)
- Remove USER directive; entrypoint handles the privilege drop instead
- Use ENTRYPOINT + CMD so Node becomes PID 1 (correct SIGTERM handling)
- Add .gitattributes to enforce LF line endings for *.sh files
- 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
The Add Node button requires both api_url AND api_token to be non-empty
before it enables. Both validation tests were only filling api_url,
leaving the button permanently disabled and timing out after 30s.
Add a dummy api_token fill in each test so the button enables and the
form submits — the backend then correctly rejects the invalid URL.
- openAddNodeAsRemote helper: clicks #node-type (Radix combobox, not native select),
picks the 'Remote' option, then waits for #node-api-url to appear — the API URL
field is conditionally rendered only when type === 'remote'
- Fix getByLabel(/node name/i) → #node-name in both tests (missed in second test)
- Use .last() for submit button to avoid matching the Add Node trigger behind the dialog
- Remove typeSelect.selectOption() which throws 'not a select element' on Radix UI
The NodeManager form labels the field 'Name' (not 'Node Name'), so
getByLabel(/node name/i) never matched and timed out after 30s.
Switch to the stable #node-name id which is bound via htmlFor.
stacks.spec.ts:
- waitForStacksLoaded: wait for 'Create Stack' button instead of [cmdk-item] > 0
CI starts with empty COMPOSE_DIR so there are no stacks; the button is always
rendered in the sidebar regardless of whether any stacks exist
nodes.spec.ts:
- beforeEach: click Settings then 'Nodes' nav item to reach NodeManager
The Add Node button lives inside Settings → Nodes; the previous beforeEach
only clicked Settings but never navigated to the Nodes section, so the
add-node button was never found and tests silently skipped
- Split serial build-and-test job into parallel backend, frontend, docker-validate, and e2e jobs
- backend job: build → test → lint (new ESLint) → npm audit --audit-level=high
- frontend job: build → lint → npm audit --audit-level=high
- docker-validate job: builds image on every PR without pushing; Trivy scans for CRITICAL/HIGH CVEs (informational)
- e2e job: runs full Playwright suite against live dev servers after backend+frontend pass
- Add backend/eslint.config.mjs and lint script to backend/package.json
- 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
When the gateway proxied a WebSocket upgrade for a remote node's interactive
terminal (host console or container exec), it injected the long-lived api_token
(scope: 'node_proxy') as the Bearer token. The remote's WS upgrade handler
correctly blocked this token via its isProxyToken guard (added in a prior
security hardening commit), causing a 403 → socket destroy → "connection error,
session ended" on the client.
Fix: introduce a POST /api/system/console-token endpoint that issues a
short-lived JWT (scope: 'console_session', 60 s TTL). Before forwarding an
interactive WS upgrade to a remote node, the gateway calls this endpoint using
the api_token, then substitutes the returned console_session token as the Bearer
header for the WS upgrade. The remote's isProxyToken guard (scope === 'node_proxy')
continues to block the long-lived api_token from spawning shells directly while
allowing gateway-delegated console sessions through.
Non-interactive WS paths (stack logs) continue to use the api_token unchanged.
The previous fix attempted to re-stream req.body via proxyReq.write() in the
proxyReq event handler. This raced against http-proxy's synchronous
req.pipe(proxyReq) call: when express.json() consumed the IncomingMessage stream,
Node.js's pipe() detected readableEnded and scheduled process.nextTick(proxyReq.end),
which fired before the proxyReq socket event. Any subsequent write() threw
"write after end", leaving the remote server stalled waiting for body bytes that
never arrived (the "Add Rule spins forever" bug).
Root fix: a conditional middleware now skips express.json() entirely for requests
targeting a remote node on non-auth, non-nodes API paths. The raw stream is left
intact, allowing http-proxy's req.pipe(proxyReq) to forward the body correctly.
express.json() fully drains the incoming request stream. http-proxy-middleware then
pipes an already-consumed empty stream to the remote Sencho instance, which holds the
connection open waiting for body bytes that never arrive (Content-Length says N but 0
bytes are forwarded). This caused all POST/PUT/PATCH requests to remote nodes — most
visibly 'Add Alert Rule' — to hang indefinitely with an infinite loading spinner.
Fix: in the proxyReq handler, write JSON.stringify(req.body) with correct Content-Type
and Content-Length headers before forwarding, restoring the full request body.
- 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
- Reject node_proxy scoped JWT tokens with 403 on host-console and
container exec (/ws) upgrades; machine-to-machine credentials must
not open interactive shells
- Validate stackParam against path.resolve + startsWith(baseDir) to
prevent directory traversal on the PTY cwd (Rule 9 pattern)
- Strip JWT_SECRET, AUTH_PASSWORD, AUTH_PASSWORD_HASH, DATABASE_URL
from the environment passed to node-pty spawned shells
- 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