* fix(app-store): harden template deploy, registry fetch, and catalogue refresh
Serialize generated compose through the YAML emitter so registry-supplied values are escaped correctly instead of interpolated into hand-built lines. Cap the registry response size so an oversized or runaway catalogue cannot exhaust backend memory, and surface the fetch failure to the caller. Reload the catalogue when the active node changes, since the registry is node-scoped. Add developer-mode deploy diagnostics (counts only, no values) and extend the unit tests with YAML round-trip, LinuxServer.io mapping, and size-cap coverage.
* fix(app-store): reset node-scoped catalogue state on fetch and bound deploy diagnostics
Clear the templates list and Trivy availability at the start of each catalogue load so a failed fetch after a node switch shows the new node's empty state instead of the previous node's catalogue or scan toggle. Bound the developer-mode diagnostic template title/source length, and document that the registry cache serves the last-known-good catalogue on a transient fetch failure (the size cap still protects memory in every case).
* fix(deploy-progress): decouple deploys from the live progress stream
The deploy progress modal streamed compose output over a WebSocket, but
the deploy itself was coupled to that socket in two ways that could break
or silently abort a deploy:
- The deploy request was gated on the progress socket connecting, so any
upgrade failure (a reverse proxy blocking WebSocket upgrades, or the
admin-only stream rejecting a scoped deployer) left the modal stuck on
"Connecting..." and the deploy never fired.
- The backend terminated the running compose process when that socket
closed, so minimizing the modal, navigating away, or a network blip
aborted an in-flight deploy.
Make the progress socket output-only: the deploy is owned by its request
and runs to completion (or the existing command timeout) regardless of
the stream. The modal now degrades to a "Live progress unavailable" state
and still reports success or failure from the request result. Connect
failures, drops, and a connect timeout all release the deploy instead of
blocking it.
Also route progress output per deploy: the frontend sends a correlation
id on both the connectTerminal message and the deploy request header, and
the backend keys progress sockets by that id so concurrent deploys from
different tabs or users no longer cross-stream each other's output.
Cap the in-memory parsed log rows so a very long deploy cannot grow the
modal's state unbounded.
* fix(deploy-progress): generate the deploy session id with a CSPRNG
The per-deploy correlation id keys which WebSocket receives a deploy's
live output, so a guessable id lets one authenticated client register a
victim's id and read its compose output. It was built from Math.random()
plus a timestamp, which is not cryptographically secure.
Generate it with crypto.getRandomValues (128 bits, hex). That is the one
Crypto member available in insecure contexts, so it still works over LAN
HTTP where crypto.randomUUID is unavailable.
* fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal
Address review findings on the progress-stream routing:
- Only an id-less connectTerminal registration may become the id-less
fallback socket. Previously every connectTerminal (including keyed deploy
modals) set the fallback, so a headerless operation (bulk update, rollback,
or a legacy client) resolved via getTerminalWs() into another user's keyed
deploy modal. Keyed sockets are now excluded from the fallback, and a socket
that adopts a session id is removed from it.
- The connect-timeout fallback now also flags the modal as "Live progress
unavailable" instead of leaving it on "Connecting..." while the deploy runs.
- Log only a short prefix of the deploy session id in developer diagnostics,
not the full capability value.
* feat(app-store): show inline port-conflict message on deploy sheet
Replace the hover-only cursor tooltip on the Advanced tab with an inline
"in use by <stack>" message rendered next to the port input. The
pulsating warning dot stays as the live-data indicator.
Add a Port-conflict warning rail to the Essentials tab that lists each
conflicting default port and the application using it. The rail replaces
the "Deploy with defaults" hint while conflicts exist, and its left
accent pulses to echo the live-data signal.
Update docs/features/app-store.mdx to describe the new visible behavior
on both tabs.
* docs(app-store): drop docs hunk from this PR
The deploy-sheet docs are being rewritten on the docs/v1-refresh branch
(PR #966). To avoid a merge conflict between the two PRs, the prose
update for the inline port-conflict messaging now lives there as a
follow-up commit on top of f0161e3. Keep this PR scoped to the UI
change only.
Final PR of the System Sheet (§9.11) chrome rollout, stacked on PR 2.
Migrates the last five sheet consumers and extracts the inline network
detail sheet from ResourcesView into its own file.
Sheets migrated:
* VolumeBrowserSheet: crumb Resources > Volumes > {name}, Refresh tree
primary, footer audit-log notice. Uses the new SystemSheet noScroll
prop because the body is a 2-pane file browser that manages its own
scroll regions; each pane (file tree, file preview) wraps its scroll
region in ScrollArea per §10 Scrollbars.
* ImageDetailsSheet: crumb Resources > Images > {name}. Three sections
(Overview, Config, Layers) flush against hairlines. Removed the
icon-prefixed title and per-layer card wrapping (replaced with
divide-y dividers).
* NetworkDetailSheet: NEW file extracted from the inline 150-line
network sheet that lived inside ResourcesView.tsx. Crumb Resources >
Networks > {name}. Five sections (Overview, IPAM, Options, Connected,
Labels). Re-exports NetworkInspectData so ResourcesView can import
the type. ResourcesView now renders the extracted component and drops
its now-unused Sheet, ScrollArea, copyToClipboard, Copy, and Container
imports.
* AppStoreView template detail sheet: crumb App store > {template}.
Tabs Essentials | Advanced. Deploy lifted from SheetFooter into the
toolbar primary slot. The remote-target signal (was a Badge in the
header) collapses into the meta line as "→ {remoteName}".
* BlueprintDetail: the §9.11 reference implementation, intentionally
migrated last. Crumb Blueprints > {name}. The kebab dropdown
dissolves into individual toolbar actions: Apply now (primary), Edit
(secondary, when not in editMode), Enable/Disable (secondary), Delete
(destructive). Body has Description, Deployments, Compose sections.
Primitive enhancement:
* Added noScroll?: boolean to SystemSheet. When true, the body is
rendered as a flex container instead of being wrapped in ScrollArea.
Caller manages its own scroll regions and body padding.
Final state: frontend/src/components/ui/sheet.tsx is now imported only
by TopBar.tsx (mobile nav drawer, intentionally out of scope per the
plan) and SystemSheet itself. The §9.11 rollout is complete.
Grid templates are now sorted by star count (descending) so popular
apps surface naturally rather than appearing in registry fetch order.
Featured hero rotates weekly among the top 5 starred apps instead of
always pinning the single highest-starred entry. Rotation is seeded
by week number so all nodes show the same featured app throughout a
given week. Registries with no star data gracefully skip the featured
hero and preserve their natural ordering.
* feat(notifications): dispatch deploy_failure alert on stack action errors
* feat(terminal): add onReady and onMessage callback props
* feat(deploy-logs): add DeployLogContext with runWithLog API
* feat(deploy-logs): add DeployLogPanel bottom drawer with resize and minimize
* feat(deploy-logs): wire DeployLogContext to App and EditorLayout action runners
* test(deploy-logs): add E2E test for deploy log panel open, failure, and minimize
* docs(deploy-logs): add user-facing and internal architecture docs
* feat(deploy-logs): redesign as opt-in modal with structured log rows
Replace the full-width bottom drawer (DeployLogPanel) with a centered
modal that streams structured log output for deploy, stop, restart,
update, install, and Git apply operations. The modal is disabled by
default; users opt in from Settings -> Appearance.
Core changes:
- New DeployFeedbackContext with runWithLog() API: if opt-in is off,
silently bypasses the UI so all call sites degrade to the existing
toast behavior without code changes.
- composeLogParser.ts: pure parser that strips ANSI escapes and
classifies compose output into stage badges (PULL, BUILD, CREATE,
START, STOP, DOWN, WARN, ERR, LOG). 15 unit tests.
- StructuredLogRow.tsx: memoized row with timestamp, stage badge, and
message. Error rows get a rose left rail; warn rows get a tinted bg.
- DeployFeedbackModal: Dialog-based, max-w-640px/max-h-70vh, elapsed
timer, auto-close 4s on success (hover cancels), persistent on
failure. Raw xterm output collapsible in footer.
- DeployFeedbackPill: minimized state anchored top-right, survives
navigation, click restores modal.
- Wires App Store install (action: install), Git apply (action: deploy),
and Git pull (action: update) in addition to the existing EditorLayout
actions.
- Fixes Terminal.tsx WS URL in generic mode (was connecting to root path
not proxied by Vite; now uses /ws).
- Settings: adds "Show deploy progress modal" checkbox to Appearance.
- Docs: renames deploy-logs.mdx to deploy-progress.mdx; updates
internal architecture doc.
* fix(deploy-logs): connect Terminal in generic mode and move pill to bottom-center
Terminal was passed stackName which routes it to the stack logs WS
(container stdout). In that mode onReady is never called, so the
deployStarted gate never resolves and the compose command never runs.
Remove stackName so Terminal uses generic WS mode, which calls onReady
on open and streams compose output.
Also reposition the minimized pill from top-right to bottom-center
(fixed bottom-6 left-1/2 -translate-x-1/2) per UX feedback.
* docs(deploy-logs): update pill position to bottom center
* test(deploy-logs): rewrite E2E spec for deploy feedback modal
The old spec targeted the removed bottom-drawer DeployLogPanel and used
the wrong field name when calling POST /api/stacks (sent 'name' but the
endpoint reads 'stackName'), causing every test to fail with a 400 before
any UI assertions ran.
Fixes:
- POST /api/stacks body now uses 'stackName' matching the API contract
- All locators updated to target the new DeployFeedbackModal and
DeployFeedbackPill components (data-testid attributes added)
- Added enableDeployFeedback helper to opt-in via localStorage before
each test that expects the modal (feature is off by default)
- Added opt-in OFF test to confirm the modal is suppressed when disabled
- Minimize/expand test now asserts the pill appears and contains the
stack name before clicking to restore the modal
* test(deploy-logs): fix compose file write endpoint in E2E helper
createStackViaApi was calling PUT /api/stacks/:name/files/docker-compose.yml
which does not exist. The correct endpoint is PUT /api/stacks/:name with
{ content } in the body.
* test(deploy-logs): use addInitScript to persist opt-in across reloads
The opt-in flag was set via page.evaluate before setupDeployStack, which
calls page.reload() and loginAs (a second navigation). Although localStorage
should persist across same-origin reloads, the React tree was reading
'false' on remount in CI. Switching to addInitScript guarantees the
localStorage value is set before any page script on every navigation, so
useDeployFeedbackEnabled's useState initializer always sees the right
value when React mounts.
* test(deploy-logs): verify localStorage and re-dispatch event before deploy
Adds syncDeployFeedbackState() called right before each deploy click in
the ON tests. It both verifies localStorage is set (failing the test
loudly with a clear message if not) and re-dispatches the
SENCHO_SETTINGS_CHANGED event to defeat any stale React state after
navigation. If the modal still does not appear with the assertion green,
the issue is downstream of localStorage and we have a clear signal.
* test(deploy-logs): wait for React re-render after dispatching opt-in event
After syncDeployFeedbackState dispatches SENCHO_SETTINGS_CHANGED, React
schedules the state update but does not flush it synchronously. The
click that follows can fire against the stale closure where isEnabled is
still false, so runWithLog takes its early-return path and the modal
never opens. A 200ms wait is enough to let React commit the new state
before the next interaction.
* test(deploy-logs): wait for stack file fetch before clicking deploy
deployStack() in EditorLayout returns early at 'if (!selectedFile)'
without calling runWithLog. selectedFile is set inside loadFile() after
GET /api/stacks/:name resolves. The previous setup clicked the stack in
the sidebar and immediately asked the test to click Deploy, racing the
fetch. CI backend logs confirmed no deploy POST ever fired for the ON
tests, while the OFF test passed only because it asserts non-existence.
Now setup awaits both the stack click and the file response together,
then verifies the action bar's deploy button is visible before returning.
* test(deploy-logs): wait for network idle and capture browser logs
Adds a networkidle wait plus a 500ms settle after the stack click so
React commits selectedFile and any follow-up env/container/backup
fetches drain before the deploy click. Also mirrors browser console
errors and pageerrors into the Playwright output so the next failure
ships with the React stack trace instead of just a 'modal not visible'
message.
* test(deploy-logs): temporary debug logging in runWithLog
Adds a console.log at the entry of runWithLog so we can see in CI logs
whether it is being called and what isEnabled value the closure has.
Also widens the test's console capture to include these debug lines.
This is diagnostic only and will be removed once the root cause of the
modal-not-opening-in-CI failure is identified.
* test(deploy-logs): debug log at deployStack entry to trace click path
Adds console.log at the first line of deployStack handler so we can
confirm in CI whether the click is reaching it at all and what
selectedFile/isStackBusy resolve to. Combined with the existing
runWithLog debug logs, this isolates whether the modal failure is in
deployStack guarding out, runWithLog early-returning, or something
else entirely.
* test(deploy-logs): drop filter, log every browser console msg
The previous filter only emitted error/warning plus the deploy-feedback
substring. The deploy-feedback debug logs never appeared, so we don't
yet know whether the log itself is firing. Remove the filter so the
full console stream shows up in CI.
* test(deploy-logs): app-level console log to verify capture pipeline
If even an unconditional log at App component render time does not
appear in CI browser logs, then the console capture listener is broken
or the dispatched logs are being filtered upstream of Playwright. This
isolates whether the issue is in the production code or the test
harness.
* test(deploy-logs): use testid locator for stack action button
Replaces the regex-based getByRole locator (/Deploy|Start/i) with
getByTestId('stack-deploy-button'). The regex matched something other
than the actual deploy button: backend logs proved no deploy POST ever
fired, and instrumentation confirmed neither deployStack nor runWithLog
ran on click despite the test claiming success.
Adds data-testid='stack-deploy-button' to both the Restart and Start
button branches in EditorLayout's action bar so the same locator works
whether the stack is running or not.
Also drops the temporary debug console.log entries in deployStack,
runWithLog, and App, and restores the test's console listener filter
to only emit error and warning messages.
* test(deploy-logs): park cursor in corner so auto-close countdown fires
After clicking the deploy button, the cursor lands inside the centered
modal. The modal pauses its 4s auto-close countdown on hover, so the
HAPPY test was waiting for a close that never happened. page.mouse.move
to (0,0) parks the cursor outside the modal before the success banner
appears, letting the countdown complete.
* test(deploy-logs): drop redundant loginAs after page.reload
page.reload preserves auth cookies, so the page lands back on the
dashboard without needing a fresh login. The loginAs call after reload
was racing on isLoginPage(): a transient login-page state during page
load made loginAs commit to filling #username, then the dashboard
committed and #username never came back. Playwright's auto-wait then
hung the fill until the test's 120s timeout, which also dragged later
stacks.spec tests down with collateral timeouts.
waitForStacksLoaded is enough to confirm we're on the dashboard with
the sidebar populated before clicking the new stack.
* test(e2e): make loginAs race-safe when login page is a false positive
isLoginPage() reports the page as a login screen if the Login button
locator reports visible at the moment of the check. Under CI load (more
real container deploys from the deploy-log-panel suite), the auth
context can render the login form for one paint, then redirect to the
dashboard. The original code committed to filling #username and hung
until the test timeout when the field was no longer there.
Now the login branch waits up to 2s for #username to actually appear
before filling. If it never appears, we fall through to the dashboard
check instead of hanging.
* feat(app-store): editorial hero, category rail, security scan signal per tile
Rework the App Store view into an editorial layout with a 180px category
rail, a featured-template hero, and compact tiles that surface star counts
and vulnerability scan status at a glance. The deploy sheet splits into
Essentials (one-click deploy) and Advanced (full port/volume/env control)
tabs.
Backend enriches /api/templates with scan_status, scan_cve_count, and a
featured flag computed from the highest-star template with recorded stars.
Scan lookups use a single batched SQL query to avoid N+1 round-trips.
* fix(app-store): split firstSentence helper into its own module
The firstSentence helper lived alongside the TemplateLogo component, which
violates react-refresh/only-export-components — a file that exports a
component must not also export non-component values, or Fast Refresh cannot
establish an HMR boundary. Move the helper into appstore/util.ts and update
the two import sites.
* feat(images): Trivy-powered vulnerability scanning
Scan container images for known CVEs via Trivy. On-demand scanning and
severity badges are available on every tier; scheduled scans, scan
policies, SBOM generation, and scan history are gated to Skipper+.
- New TrivyService (binary detection, per-image scan, SBOM, digest cache)
- Three new tables: vulnerability_scans, vulnerability_details, scan_policies
- 12 routes under /api/security (scan, results, summaries, SBOM, policies, compare)
- Post-deploy async scans wired into all five deploy paths, with a
per-deploy opt-out toggle in the App Store deploy sheet
- "scan" action type added to SchedulerService for fleet-wide recurring scans
- Frontend: severity badges in Resources Hub with animated cursor detail,
scan results drawer with vulnerability table and filters, and a new
Security section in Settings for scan policy CRUD
- Policy threshold violations dispatch a warning or critical alert based on
the policy's block_on_deploy flag; deploys themselves are never blocked
* fix(security): compute scan age in useEffect to satisfy react-hooks/purity
Show a pulsating warning dot next to host ports that are already in
use by a running container. Hovering over the dot reveals which
Sencho-managed stack or external app occupies the port.
Adds GET /api/ports/in-use endpoint that returns a map of bound host
ports with ownership info, and a getPortsInUse method on
DockerController that reuses the existing container-to-stack
resolution logic.
* fix(app-store): harden App Store with auth, validation, bug fixes, and design compliance
Add authMiddleware to GET /api/templates and POST /api/templates/deploy
endpoints. Add isValidStackName and isPathWithinBase checks to the deploy
endpoint. Replace fs.existsSync with async fsPromises.access. Extract
FileSystemService to local variable to avoid repeated getInstance calls.
Fix template mutation bug where PUID/PGID/TZ duplicated on re-open by
working on a copy instead of mutating state. Make env_file conditional
in generated compose YAML (only when env vars exist). Add port validation
(range 1-65535) with visual feedback and deploy blocking. Add env key
collision warning toast for custom variables.
Replace any types with proper LSIO API interfaces. Change catch types
from any to unknown with getErrorMessage. Add structured logging with
[Templates] prefix and diagnostic logging gated behind Developer Mode.
Align with design system: remove hardcoded bg-white and text-red-500,
use ScrollArea, fix destructive button variant, add tabular-nums and
strokeWidth 1.5, use cn() for conditional classes. Add empty-registry
state distinct from no-search-results.
Add 16 unit tests for TemplateService covering compose generation,
conditional env_file, env string generation, and cache clearing.
Update App Store docs with port validation and env collision details.
* refactor(app-store): remove unused interface exports
Remove export keyword from interfaces that are only used within their
own file: TemplateEnv, TemplateVolume, and TemplatesResponse in
TemplateService.ts; TemplateEnv and Template in AppStoreView.tsx.
No external consumers import these types.
* fix(app-store): remove unused fs default import
The fs.existsSync call was replaced with fsPromises.access in the
deploy endpoint, leaving the fs default import unused. Remove it
to fix the ESLint no-unused-vars error in CI.
* fix(stacks): harden stack management with security fixes, validation alignment, and logging
Validate WebSocket stack names with isValidStackName() to close a
path-traversal gap on the /api/stacks/:stackName/logs WS endpoint.
Align POST /api/stacks to use the canonical validator (allows underscores).
Replace error: any catch blocks with error: unknown + type narrowing.
Add cache invalidation to PUT /api/stacks/:stackName/env.
Rename DELETE param from :name to :stackName for consistency.
Add standard [Stacks] lifecycle logs and diagnostic [Stacks:debug] logs
gated behind the Developer Mode toggle (with 5s TTL cache).
Extract shared isDebugEnabled() and getErrorMessage() utilities.
Frontend: roll back optimistic status on API failure, guard unsaved
changes when switching stacks, pre-check duplicate names in App Store.
* docs(settings): update Developer Mode description to mention debug diagnostics
* refactor(ui): replace Sonner with custom Sera UI-inspired toast system
Replace the Sonner toast library with a custom implementation inspired by
Sera UI's glassmorphism design. The new system uses an external store pattern
with useSyncExternalStore for React integration, keeping the same
toast.success()/error()/warning() API so all 172 call sites required only
an import path change.
Key changes:
- New toast-store.ts: singleton store with identical API to Sonner
- New toast.tsx: Sera UI-faithful Notification component with Framer Motion
animations, frosted glass (backdrop-blur-xl), type gradient overlays,
animated progress bar (green→blue→sky gradient), and hover:scale-105
- Removed sonner and next-themes dependencies
- Rewired all 19 consumer files to import from the new store
* fix(ui): resolve ESLint errors in toast system
- Use const for listeners Set (prefer-const)
- Initialize startRef with 0 instead of Date.now() to satisfy
react-hooks/purity rule, set actual value inside useEffect
* feat(rbac): add Deployer & Node Admin roles with scoped permissions (Team Pro)
Add intermediate RBAC roles gated to Team Pro tier:
- Deployer: can deploy/restart/stop/start stacks but cannot edit compose files, delete stacks, or access system settings
- Node Admin: full stack and node management within scope, no system settings access
- Scoped permissions: assign roles per-stack or per-node for fine-grained access control
- Permission engine with checkPermission/requirePermission guards replacing requireAdmin on stack/node routes
- Frontend can() function with /api/permissions/me endpoint for client-side permission checks
- User management UI updated with 4-role selector and scoped permission editor
- Documentation updated with permission matrix, scoped permission docs, and screenshots
* fix(rbac): remove unused RoleAssignment import to fix lint error
* feat: add RBAC viewer accounts, atomic deployments, and fleet-wide backups (Pro)
Introduces three Pro-tier features:
- RBAC: Multi-user system with admin/viewer roles, user management UI,
automatic migration from single-admin credentials, viewer restrictions
across the entire UI (read-only editor, hidden action buttons)
- Atomic Deployments: Pre-deploy file backup to .sencho-backup/, automatic
rollback on health probe failure, manual rollback button, health probes
added to stack updates, webhook-triggered deploys use atomic rollback
- Fleet-Wide Backups: Point-in-time snapshots of compose files across all
nodes (local + remote), stored centrally in SQLite, per-stack restore
with optional redeploy, graceful handling of offline nodes
* fix(settings): use correct ProGate prop name in UsersSection
* fix(settings): remove unused isPro prop from UsersSection
* fix(auth): fetch user info after login and setup so isAdmin is set correctly
- 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
- 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.
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