The "Featured · most-deployed" banner duplicated information already
present on the blueprint tile grid below it and added vertical clutter
to the Deployments tab. Drop the callout and the useMemo that picked
its target.
Wire the snapshot_then_evict withdraw mode to actually persist the
blueprint's compose YAML to fleet_snapshots before running the
eviction. The mode previously recorded intent only. Capture failure
aborts the eviction with HTTP 500 rather than silently falling
through to a destructive withdraw.
Volume bytes remain out of scope: the snapshot holds the compose
definition only. UI copy and the Blueprints docs (Withdraw note,
Migrating stateful data section, two new Troubleshooting entries)
clarify that operators must move volumes by hand if they need the
data on another node.
Adds 9 route-level tests covering the success path, snapshot DB
write failure, orphan-row cleanup when insertSnapshotFiles fails,
empty compose_content, evict_and_destroy unchanged, stateless
unchanged, evict_blocked gate, omitted confirm field, and bad
confirm value.
Subdivide the 40-page Features sidebar into six capability subgroups
(Stacks & Deployments, Observability, Fleet & Multi-Node, Security &
Identity, Automation, Platform) so users find features by purpose
rather than scrolling a flat list. Move the licensing page into the
Reference group where it belongs and reorder Reference for scan-ability.
Reorder the Operations group by lifecycle: install, upgrade, backup,
troubleshoot, then per-feature admin tools.
Nav-only change. No .mdx files were moved or renamed; every page keeps
its existing URL. Tier badges already inside each page are unchanged.
Drop the SENCHO_EXPERIMENTAL gate from the Fleet Routing and
Deployments tabs so they ship in the default UI. Both have been
verified production-ready and promoted out of experimental.
Routing trigger and content are now wrapped only by isAdmiral plus
the existing AdmiralGate. Deployments trigger and content are wrapped
by isPaid (Skipper+); Community users no longer see the tab at all,
mirroring the Routing pattern. Federation and Secrets remain inside
the experimental block as dev-only previews.
Removes the SoonBadge component and "Coming soon" pill from the
preview placeholder so the tab bar shows only ready, tier-appropriate
tabs without ambiguous SOON labels.
Final phase E pass over the experimental blueprint surfaces (gated by
SENCHO_EXPERIMENTAL).
- BlueprintDetail's destructive delete dialog -> Modal +
ModalDestructiveHeader. Kicker BLUEPRINT · DELETE · IRREVERSIBLE.
Keeps the type-to-confirm input pattern in the body and uses a
destructive-variant Button as the primary footer action
- DeploymentsTab's New Blueprint editor -> Modal + ModalHeader.
Kicker BLUEPRINTS · NEW. The wide editor sits inside ModalBody;
cancel/submit live inside the BlueprintEditor itself
Four settings panels with per-row destructive confirms now share the
§10 ConfirmModal chrome. Each per-row AlertDialog is replaced by a
single parent-level ConfirmModal driven by per-target state, with the
row's button calling setTarget(item).
- UsersSection: Reset 2FA confirm (default variant) + Delete user
confirm (destructive)
- CloudBackupSection: Delete cloud snapshot confirm (destructive)
- RegistriesSection: Delete registry confirm (destructive)
- ApiTokensSection: Revoke token confirm (destructive)
Drop the unused AlertCircle import in CloudBackupSection that the
old AlertDialog header relied on.
- modal.tsx Modal now accepts a showClose prop that passes through to
DialogContent, so callers that render their own close affordance
(DeployFeedbackModal) can suppress the Radix close button
- DeployFeedbackModal -> Modal with showClose=false. Custom header,
scroll body, embedded terminal, and footer stay in place; Modal
provides the consistent overlay/blur chrome. DialogTitle is still
imported from the dialog primitive purely as an sr-only
accessibility wrapper, since this status panel doesn't fit the §10
ModalHeader chrome
- BashExecModal -> Modal + ModalHeader. Kicker BASH · {CONTAINER}.
The xterm container sits in the body
- LogViewer -> Modal + ModalHeader. Kicker LOGS · {CONTAINER}. The
scroll region sits in the body
* refactor(frontend): migrate Stack/Fleet confirms and Git source dialog
- LocalUpdateConfirmDialog -> ConfirmModal with kicker LOCAL · UPDATE
- GitSourcePanel form -> Modal with kicker {STACK} · GIT SOURCE; the
custom three-button footer (Remove / Pull now / Save) stays as-is
since ModalFooter's two-slot pattern doesn't fit a left-aligned
destructive action; ModalHeader provides the canonical chrome
- GitSourcePanel remove confirm -> destructive ConfirmModal with
kicker {STACK} · GIT · DISCONNECT
* test(e2e): scope git-source dialog assertion to the heading
The migration adds a kicker rune "<STACK> · GIT SOURCE" inside the
dialog. The previous locator getByText('Git Source', { exact: false })
matched both the kicker (uppercase, mono) and the italic-serif title,
producing a strict-mode violation. Use getByRole('heading') to target
the title specifically.
Two AlertDialog instances move onto §10 Modal primitives:
- Snapshot delete confirm -> destructive ConfirmModal hoisted to a
single parent-level instance driven by confirmDeleteId state. Each
row's trash button now calls setConfirmDeleteId(snapshot.id) instead
of wrapping its own AlertDialog
- RestoreButton's per-restore confirm -> ConfirmModal with the
redeploy checkbox in the body slot. Promise-aware onConfirm closes
the dialog from finally
* refactor(frontend): migrate NodeManager dialogs to Modal chrome
Bring all four NodeManager dialogs onto §10 Modal primitives:
- Add node form -> Modal at lg, kicker NODES · ADD LOCAL or
NODES · ADD REMOTE depending on the form's type radio. The Add
button is now a normal button that calls setCreateOpen(true)
rather than a DialogTrigger
- Edit node form -> Modal at lg, kicker NODES · EDIT
- Pilot enrollment dialog -> Modal at xl, kicker NODES · PILOT ENROLLMENT
- Delete confirm -> destructive ConfirmModal, kicker
NODES · DELETE · IRREVERSIBLE
handleDelete now closes from finally so the dialog clears on errors
too, matching the ConfirmModal Promise-aware contract.
* fix(frontend): make ModalBody scrollable, scope nodes test locator to dialog
Two related fixes for the Add Node modal regression on small viewports:
1. ModalBody now caps at max-h-[calc(85vh-12rem)] with overflow-y-auto.
Tall forms that would push the footer offscreen on a 720px viewport
(Add Node has 6 form sections plus header and footer) now scroll
inside the body while the cyan-rail header and Cancel/Submit footer
stay anchored. Benefits every form modal, not just NodeManager.
2. e2e/nodes.spec.ts now scopes the submit-button locator to
getByRole('dialog'), removing the .last() pattern. The previous
approach worked when the modal was guaranteed to render after the
trigger in DOM order, but it doesn't survive an offscreen footer.
NotificationRoutingSection:
- Routing rule create/edit form -> Modal at lg with kicker
ROUTING · NEW RULE or ROUTING · EDIT RULE
- Per-row delete AlertDialog -> a single parent-level destructive
ConfirmModal driven by deleteRouteId state, opened by each row's
trash button (no more inline AlertDialogTrigger pattern)
- handleDelete tightened to close from finally so the dialog clears on
errors too
SecuritySection:
- Policy create/edit form -> Modal at md with kicker
SECURITY · NEW POLICY or SECURITY · EDIT POLICY
- Policy delete confirm -> destructive ConfirmModal with kicker
SECURITY · DELETE · IRREVERSIBLE
- Trivy uninstall confirm -> destructive ConfirmModal with kicker
TRIVY · REMOVE · IRREVERSIBLE
- handleDelete already used finally; handleUninstallTrivy already
closes the dialog before awaiting, so it works with the
ConfirmModal Promise-aware path
Bring the two settings panels onto §10 Modal primitives.
LabelsSection:
- Create/edit form -> Modal at sm with kicker LABELS · NEW or
LABELS · EDIT
- Delete confirm -> destructive ConfirmModal with kicker
LABELS · DELETE · IRREVERSIBLE
- handleDelete now closes from finally so the dialog clears on errors
too (the new ConfirmModal Promise-aware behaviour keeps it open until
state closes it)
SuppressionsPanel:
- New suppression form -> Modal at md with kicker SUPPRESSIONS · NEW
- Remove confirm -> destructive ConfirmModal with kicker
SUPPRESSIONS · REMOVE · IRREVERSIBLE
- handleDelete already closed from finally; no change needed there
Bring the create/edit task form and the delete confirm onto the §10
Modal primitives:
- Create/edit form -> Modal + ModalHeader + ModalBody + ModalFooter at
size lg, with kicker SCHEDULER · NEW TASK or SCHEDULER · EDIT TASK
- Delete confirm -> destructive ConfirmModal with kicker
SCHEDULER · DELETE · IRREVERSIBLE and a tighter body
Tighten handleDelete to close the dialog from finally so the
ConfirmModal Promise-aware behavior closes cleanly on both success and
error. handleSave intentionally keeps the form open on error so the
user can fix and retry.
Replace raw Dialog/AlertDialog usage in ResourcesView with the §10 Modal
primitives:
- Prune confirms (managed and all scopes) -> destructive ConfirmModal
with single shared kicker and scope-conditional title/hint/label
- Delete confirms (image, network, volume) -> destructive ConfirmModal
- Bulk purge of unmanaged containers -> destructive ConfirmModal
- Create network form -> Modal + ModalHeader + ModalBody + ModalFooter
Also enforce single-line kickers in the modal primitive by adding
whitespace-nowrap to the header kicker, so the rune at the top never
wraps even at sm width.
Tighten handlePurgeOrphans to close the dialog in finally rather than
only on success, since the new ConfirmModal Promise-aware behaviour
keeps the dialog open until state closes it.
- Change network metrics (net_rx/net_tx) from cumulative totals to MB/s rates
so alert thresholds are operationally meaningful
- Wrap all external calls (Docker stats, systeminformation, docker df) in
10-second timeout via Promise.race to prevent hung operations from
blocking the evaluation loop indefinitely
- Parallelize host CPU/RAM/disk queries with Promise.all to bound worst-case
latency at 10 seconds instead of 30
- Use epsilon comparison for == operator so floating-point metric values
can match integer thresholds
- Clean up stale entries in activeBreaches and previousNetworkStats maps
after rules are deleted or containers stop
- Add standard INFO logging for alert firings and WARN logging for slow
cycles and timeouts; add diagnostic cycle timing and breach-count log
* fix: suppress ERROR logging for missing .env files in image update scan
The ImageUpdateService logged a full ERROR stack trace for every stack
without a .env file, which is a normal and expected configuration.
Also added a 5-minute check timeout, developer_mode diagnostic logging,
and proper startup timeout cleanup.
* fix: add missing Node fields in test mock to satisfy tsc strict checking
* fix: remove unused variables to satisfy ESLint no-unused-vars
sanitizeForLog() already strips CR, LF, and control characters from user
input before logging, but CodeQL did not recognize the custom sanitizer.
This caused false-positive log-injection alerts at every call site.
Add a CodeQL data extension model that marks the return value of
sanitizeForLog() as a barrier against log-injection taint, and a
codeql-config.yml that references it.
* feat: open security basics, manual fleet ops, and basic fleet management to Community
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
* feat: add node last-contact tracking, fleet latency, and stack-restart summary
- DatabaseService: add last_successful_contact column to nodes table via
idempotent migration; expose updateNodeLastContact() and getStackRestartSummary()
methods; include the column in NODE_COLUMNS so getNodes/getNode return it
- fleet.ts: record latency_ms and last_successful_contact on each remote
node overview fetch; pilot-agent nodes surface pilot_last_seen instead;
pass db singleton into fetchRemoteNodeOverview to avoid redundant getInstance calls
- dashboard.ts: replace /recent-activity with /stack-restarts endpoint that
groups notification_history events by stack and category (crash/autoheal/manual)
over a configurable window (default 7 days, max 30)
* refactor(dashboard): remove redundant per-route authMiddleware
All routes under /api/ are covered by the global auth gate in app.ts.
The inline authMiddleware arguments on /configuration and /stack-restarts
were redundant with that gate and inconsistent with every other route in
the file. Remove them and drop the now-unused import.
* refactor(backend): consolidate Date.now(), move SQL aggregation, normalize node row mapping
- Capture a single completedAt timestamp in fetchRemoteNodeOverview to
eliminate two separate Date.now() calls and ensure latency_ms and
last_successful_contact are derived from the same instant
- Inline the redundant contactedAt variable; use completedAt directly
- Move stack-restart aggregation from JS into SQL (GROUP BY stack_name
with CASE/SUM counts), replacing the Map loop in the route handler
- Export StackRestartSummary interface from DatabaseService and remove
the duplicate local definition in dashboard.ts; handler now returns
the query result directly
- Add last_successful_contact normalization in decryptNodeRow, mirroring
the existing pilot_last_seen pattern
- Add authGate reliance comment above dashboardRouter route handlers
* feat(dashboard): replace Recent Activity card with context-aware Fleet Heartbeat / Stack Restart Map
- Multi-node installs (≥1 remote node): shows Fleet Heartbeat — real-time
reachability, latency, and container count per registered node
- Local-only installs: shows Stack Restart Map — 7-day restart frequency
per stack grouped by crash / auto-heal / manual category
- Conditional wrapper (DashboardActivityCard) switches states automatically
when the node list changes, with no page reload required
- Deletes RecentActivity card and hook (duplicated data already in Recent Alerts)
- Extracts formatRelativeTime to frontend/src/lib/utils.ts for reuse
* fix(dashboard): add pilot_last_seen to FleetNodeOverview and use it in getLastSeenLabel
* fix(fleet): expose mode and pilot_last_seen in overview, consolidate formatRelativeTime, drop em dash
- Add `mode` and `pilot_last_seen` (in seconds) to the FleetNodeOverview
interface and to both the pilot-agent and HTTP-proxy return paths in
fetchRemoteNodeOverview so the frontend getLastSeenLabel pilot branch
can fire correctly
- Remove the private formatRelativeTime from RecentAlerts.tsx and use
the shared implementation from lib/utils, converting the millisecond
timestamp at the call site
- Replace the em dash in getLatencyLabel with 'n/a' per project rules
* feat(sidebar): add filter toggle and fix action button padding
- Add collapsible filter chip row in SidebarFilterChips with Plus/Minus
toggle button pinned to the far right of the row
- Persist expanded/collapsed state across reloads via localStorage key
sencho:sidebar:filters-visible (default expanded)
- Active filter chip stays applied while the row is hidden; hiding is
purely a visual noise reduction and does not reset the filter
- Fix flex overflow in SidebarActions by adding min-w-0 to the flex-1
wrapper around the Create Stack slot, restoring the correct 16px right
padding on the scan button
- Cap displayed counts at 99+ to bound chip render width; chips use
min-w-0 overflow-hidden instead of shrink-0 so they flex-shrink
proportionally rather than hard-clipping the last chip
* test: resolve failing CI tests and act warnings (#933)
- Fix SidebarActivityTicker to properly filter out stale activities
- Update StackGroup test to handle pinned star prefixes
- Refactor useNotifications tests to use waitFor and suppress un-awaitable act() warnings
* fix: add missing beforeAll/afterAll imports to useNotifications test
The tsc build step failed because beforeAll and afterAll were used but
not imported from vitest, unlike the other vitest functions already in
the import statement.
* fix: resolve ESLint errors in test and sidebar files
Replace any[] with unknown[] in useNotifications.test.ts console.error
mock, and add a comment to the empty catch block in StackSidebar.tsx.
* feat: open security basics, manual fleet ops, and basic fleet management to Community
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
* feat: add node last-contact tracking, fleet latency, and stack-restart summary
- DatabaseService: add last_successful_contact column to nodes table via
idempotent migration; expose updateNodeLastContact() and getStackRestartSummary()
methods; include the column in NODE_COLUMNS so getNodes/getNode return it
- fleet.ts: record latency_ms and last_successful_contact on each remote
node overview fetch; pilot-agent nodes surface pilot_last_seen instead;
pass db singleton into fetchRemoteNodeOverview to avoid redundant getInstance calls
- dashboard.ts: replace /recent-activity with /stack-restarts endpoint that
groups notification_history events by stack and category (crash/autoheal/manual)
over a configurable window (default 7 days, max 30)
* refactor(dashboard): remove redundant per-route authMiddleware
All routes under /api/ are covered by the global auth gate in app.ts.
The inline authMiddleware arguments on /configuration and /stack-restarts
were redundant with that gate and inconsistent with every other route in
the file. Remove them and drop the now-unused import.
* refactor(backend): consolidate Date.now(), move SQL aggregation, normalize node row mapping
- Capture a single completedAt timestamp in fetchRemoteNodeOverview to
eliminate two separate Date.now() calls and ensure latency_ms and
last_successful_contact are derived from the same instant
- Inline the redundant contactedAt variable; use completedAt directly
- Move stack-restart aggregation from JS into SQL (GROUP BY stack_name
with CASE/SUM counts), replacing the Map loop in the route handler
- Export StackRestartSummary interface from DatabaseService and remove
the duplicate local definition in dashboard.ts; handler now returns
the query result directly
- Add last_successful_contact normalization in decryptNodeRow, mirroring
the existing pilot_last_seen pattern
- Add authGate reliance comment above dashboardRouter route handlers
* feat(dashboard): replace Recent Activity card with context-aware Fleet Heartbeat / Stack Restart Map
- Multi-node installs (≥1 remote node): shows Fleet Heartbeat — real-time
reachability, latency, and container count per registered node
- Local-only installs: shows Stack Restart Map — 7-day restart frequency
per stack grouped by crash / auto-heal / manual category
- Conditional wrapper (DashboardActivityCard) switches states automatically
when the node list changes, with no page reload required
- Deletes RecentActivity card and hook (duplicated data already in Recent Alerts)
- Extracts formatRelativeTime to frontend/src/lib/utils.ts for reuse
* fix(dashboard): add pilot_last_seen to FleetNodeOverview and use it in getLastSeenLabel
* fix(fleet): expose mode and pilot_last_seen in overview, consolidate formatRelativeTime, drop em dash
- Add `mode` and `pilot_last_seen` (in seconds) to the FleetNodeOverview
interface and to both the pilot-agent and HTTP-proxy return paths in
fetchRemoteNodeOverview so the frontend getLastSeenLabel pilot branch
can fire correctly
- Remove the private formatRelativeTime from RecentAlerts.tsx and use
the shared implementation from lib/utils, converting the millisecond
timestamp at the call site
- Replace the em dash in getLatencyLabel with 'n/a' per project rules
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
* fix(frontend): tighten bell notification panel toolbar
Restructure the popover so the segmented filter (All / Unread / Alerts)
and the action icons (filter toggle, mark all read, clear all) share a
single row inside the 360px panel. The previous layout wrapped the
segmented control onto a second row once the type filter was added.
Move the unread badge to the right of the title, collapse the node and
type dropdowns behind a filter-toggle icon (accent dot signals an active
filter), equalize both Select widths, and extract the duplicated
className strings into local constants.
* docs: refresh bell notification popover screenshot
Reflects the reworked toolbar (segmented filter + filter toggle +
mark-all-read + clear-all on a single row).
* fix(frontend): align sidebar brand box with top nav chrome
Match the TopBar's 56px height so the bottom border of the brand
box meets the bottom border under the nav, removing the 7px seam.
Drop the duplicate uppercase "SENCHO" label and place the version
number baseline-aligned next to the italic wordmark. Center the
brand row, enlarge the logo (28→36px) and wordmark (22→28px), and
mark the logo as decorative since the wordmark already names it.
* test(e2e): key dashboard sentinel off logo src instead of alt text
The brand box change made the sidebar logo decorative (alt=""), since
the adjacent wordmark already names the brand. The Playwright helper
keyed loginAs() off img[alt="Sencho Logo"], so every login wait timed
out and 11 specs failed (with 25 cascading skips via suite teardown).
Switch DASHBOARD_INDICATOR to img[src*="sencho-logo"]: same DOM target,
unaffected by accessibility wording. The selector is unique to the
authenticated sidebar — login/setup screens do not render it.
* feat(volumes): add read-only volume browser
Adds a browser for the contents of any Docker named volume. Click the
folder icon on a volume row (admin only) to open a sheet with a directory
tree on the left and a file viewer on the right.
Backend
-------
New VolumeBrowserService spawns a one-shot Alpine 3.20 helper container
with the target volume mounted read-only at /v. The container runs as
nobody (65534:65534) with a read-only rootfs, no network, all caps
dropped, no-new-privileges, and capped at 64 PIDs and 128 MiB. The
helper image is pulled on first use per node.
Listing and stat use a portable busybox-compatible shell loop (find
-printf is not available on Alpine). Reads use head -c with an
explicit -- separator; the helper's working directory is /v so user
paths are passed as ./<path> argv elements and never as flags. The
container lifecycle is managed manually (create, attach, start, wait,
remove) to avoid the AutoRemove race where dockerode sees a 404 on
its post-exit container lookup.
Path safety: relative paths are sanitized server-side, rejecting
parent-escape segments, absolute paths, null bytes, and oversized
input. Symlinks are listed but never followed on read. Files larger
than 5 MB are truncated; binary content is detected via null-byte
scan and returned base64-encoded. Non-zero helper exits map to
404, 403, or 500 by classifying stderr.
Routes mounted at /api/volumes:
- GET /:name/list?path=
- GET /:name/stat?path=
- GET /:name/read?path=
All three require admin. The read endpoint always inserts an audit
log row (success or failure) with the actual response status code,
volume name, and relative path.
Frontend
--------
FileTree generalized to take a loadDir callback and a sourceKey
instead of a hard-coded stackName. The single existing consumer
(StackFileExplorer) was updated and its tests rewritten. The loader
is read through a ref so re-creating the arrow on every parent
render does not re-trigger the root fetch effect.
New VolumeBrowserSheet renders the tree against the volume API,
shows file content (hex view for binaries), and surfaces truncation.
Rapid sheet open and reopen on different volumes is generation-
checked to avoid stomping the visible result with a stale read.
A persistent footnote reminds the user that file reads are recorded
in the audit log, and the docs page warns about the typical contents
of database volumes.
Tests
-----
15 new vitest cases cover the pure helpers (path traversal, volume
name validation, binary detection). The Docker-facing exec path is
exercised by manual end-to-end via curl against a seeded volume.
* fix(volumes): truncate long volume names in browser sheet header
Wide volume names overlapped the close X. Reserve right padding on
the header, set min-w-0 on the flex title, mark the icon and refresh
button shrink-0, and truncate the name span.
* fix(volumes): satisfy lint on volume browser additions
prefer-const on sanitizeRelPath's local; drop unused FileTree entry
arg from the file-select callback (variance lets the arrow take fewer
params than the contract).
Adds a read-only inspect panel for Docker images. Click the eye icon on
any image row to open a sheet showing:
- Overview: ID (with copy), size, created date, arch/OS, author, tags
- Config: Cmd, Entrypoint, WorkingDir, User, exposed ports, env (collapsible),
labels (collapsible)
- Layers: ordered history list with size, age, and build command per layer.
Empty layers (metadata-only) are dimmed.
Backend adds DockerController.inspectImage(id) which combines image.inspect()
and image.history() in parallel, exposed via GET /api/system/images/:id.
The route accepts both bare hex IDs and sha256-prefixed IDs, since the list
endpoint surfaces the prefixed form. Returns 400 for malformed IDs and 404
for missing images.
Documents the new panel in docs/features/resources.mdx under Images.
Remove the page title (icon + 'Resources Hub' heading + remote-node
indicator) so the Reclaim hero leads the page. Active node identity is
already shown by the global node selector chip, so the inline indicator
was redundant.
Move the Scan history button out of the removed header and into the
secondary navigation bar, far right of the Images / Volumes / Networks /
Unmanaged tabs. Vertical centering comes from flex items-center on the
wrapper, which matches the h-9 TabsList height. Tier guard
(trivy.available && isPaid) is unchanged.
Move the update workflow state machine and the polling driver out of the
FleetView shell into two dedicated hooks under FleetView/hooks/. No
behavior change.
useFleetUpdateStatus owns updateStatuses + updatingNodeId + the four
modal/dialog/reconnecting state slots, the synchronously-held
updateStatusesRef, and every callback that touches the update workflow
(fetchUpdateStatus, triggerNodeUpdate, confirmLocalUpdate,
triggerUpdateAll, dismissNodeUpdate, retryNodeUpdate). The inline four-
line "Check Updates" handler collapses into a single checkUpdates()
callback returned from the hook.
useFleetPolling is a pure side-effect hook that owns the initial-mount
fetch, the paid-tier 30s overview + 120s update-status interval pair,
and the 5s fast-poll accelerator gated on hasUpdatingRef. The polling
hook does not know about update semantics; the consumer passes in
updateStatuses and the fetch callbacks.
Shell drops from 523 to 385 LOC. useState calls drop from 13 to 6,
useEffect from 4 to 0, useCallback from 8 to 2, useRef from 2 to 0.
Tested manually in browser: fleet view loads, masthead populates, Check
Updates opens the sheet and fetches statuses, Refresh fires
fetch-overview, no console errors from the refactor (existing
unreachable-remote-node WebSocket failures are unrelated). Dev servers
killed after validation.
* refactor(frontend): extract useFleetPreferences + useFleetLabels from FleetView (F5-6)
Move localStorage preferences and label palette/assignment fetching out of the
FleetView shell into dedicated hooks under FleetView/hooks/:
- useFleetPreferences: wraps PREFS_KEY, loadPreferences, savePreferences, and
the prefs useState + updatePrefs callback. Defaults are merged on load so stale
stored values cannot produce missing keys. Save is a side-effect-free useEffect
rather than a setState updater call, consistent with React purity contract.
- useFleetLabels: wraps fleetPalette, fleetStackLabelMap, labelFilters state,
fetchLabelsForNodes callback, and the onlineNodeKey-gated fetch effect. The
onlineNodeKey derivation is memoized. labelPaletteKey is exported for the
shell processedNodes useMemo until F5-8 absorbs it.
Shell useState: 17 to 13. useEffect: 5 to 4. useCallback: 10 to 8.
* fix(frontend): add comments to empty catch blocks in useFleetPreferences
Empty catch blocks trigger the no-empty lint rule. Add explanatory comments
to both catch sites to satisfy the rule while keeping the intent clear.
* refactor(frontend): extract NodeCard and OverviewTab from FleetView (F5-3+F5-5)
Folds F5-3 (NodeCard) and F5-5 (OverviewTab) into a single PR since
NodeCard was never previously extracted.
- Move FleetNodeStats, FleetNodeSystemStats, FleetNode into FleetView/types.ts
- Extract NodeCard (~200 LOC) including UsageBar, ContainerRow, StackSection
sub-components and getNodeCpu/getNodeMem/getNodeDisk/isCritical helpers
- Extract OverviewTab (~115 LOC); delegates to NodeCard, OverviewToolbar,
FleetTopology; receives all state as flat props from FleetView
- FleetView.tsx drops from ~1,107 to ~480 LOC (overview inline body gone)
- No logic moved; all state, hooks, and computed values remain in FleetView.tsx
- formatBytes consolidated to @/lib/utils; node helpers exported from NodeCard
- allNodes wrapped in useMemo to prevent unnecessary child re-renders
* fix(frontend): move node utility functions to nodeUtils.ts to fix react-refresh lint error
getNodeCpu, getNodeMem, getNodeDisk, and isCritical were exported from NodeCard.tsx
alongside a React component, violating the react-refresh/only-export-components rule.
Moving them to a dedicated nodeUtils.ts resolves the ESLint error without changing any logic.
Swaps Dialog for Sheet in NodeUpdatesSheet (renamed from
NodeUpdatesModal). Sheet provides full viewport height, removing the
max-h-[85vh] cap on the container and the max-h-[40vh] cap on the node
list scroll area. Width fixed at 700px.
Also fixes two stat-counter bugs carried over from the original inline
code: completed nodes now count toward the Up to date tile, and the
gateway latest-version label now resolves via the local node entry
rather than relying on array position.
No prop or behavior changes.
Moves the inline Node Updates dialog (~192 LOC), Local Update confirmation
dialog (~19 LOC), UpdateStatusBadge sub-component (~58 LOC), and shared
NodeUpdateStatus type into dedicated files under FleetView/. Shell drops
from 1,556 to 1,280 LOC.
Modal-local state (modalSearch, recheckingUpdates) and the
updatableRemoteCount derived value move into NodeUpdatesModal.
writeFileSync throws ENOENT when the parent directory does not exist
in a freshly cloned website repo. Call mkdirSync with recursive:true
on the posts dir before writing the file.
Move the inline ReconnectingOverlay sub-component out of FleetView.tsx
into its own file under FleetView/. Pure file relocation; logic and
rendered output are unchanged.
FleetView.tsx: 1,630 -> 1,556 LOC. First step in the FleetView
decomposition tracker.
* refactor(frontend): extend Modal size system with wide variant
Adds 'wide' (max-w-5xl w-[95vw]) to ModalSize for dialogs that render
Monaco DiffEditor side-by-side, which need ~1024px to display both panels
clearly. The existing 'xl' cap at max-w-xl (576px) is too narrow for that
use case.
* refactor(frontend): migrate ComposeDiffPreviewDialog to Modal chrome (D-5)
Replaces raw Dialog/DialogHeader/DialogFooter with Modal size="wide",
ModalHeader, and ModalFooter. Kicker carries the stack name and file type
context; title is the file name (accessible dialog name for tests). Footer
hint reuses the existing "ON DISK → UNSAVED" label via the hint prop.
* refactor(frontend): migrate GitSourceDiffDialog to Modal chrome (D-5)
Replaces Dialog + nested AlertDialog with Modal size="wide", ModalHeader,
ModalFooter, and ConfirmModal. Kicker is "GIT · PULL PREVIEW"; title is
the stack name. Short SHA moves to the sr-only description. The "Deploy
after apply" checkbox in the footer hint slot uses normal-case and
tracking-normal on the Label to prevent KICKER_CLASS uppercase/tracking
from being inherited. The overwrite confirmation uses ConfirmModal with
variant="destructive" (rose rail) since it replaces local file content.
* refactor(frontend): migrate CreateStackDialog to Modal chrome system (D-4)
Swap raw shadcn Dialog/DialogContent/DialogHeader/DialogFooter for the
Modal/ModalHeader/ModalBody/ModalFooter primitives shipped in D-1 and
introduce an inline ModeRail to replace the TabsHighlight chip. The new
chrome carries the cyan rail, mono kicker (STACKS · NEW), italic serif
title, and contextual footer hints per mode (ALPHANUMERIC · HYPHENS,
HTTPS REPOS ONLY, CONVERT FIRST / YAML READY + line-count accent).
ModeRail implements the full WAI-ARIA tabs pattern: aria-selected on the
active tab, aria-controls/role=tabpanel/aria-labelledby linkage to each
mode panel, roving tabIndex (active=0, inactive=-1), and ArrowLeft /
ArrowRight / Home / End keyboard navigation matching the contract that
the prior shadcn Tabs primitive provided.
Each mode also gains an explicit Cancel button alongside the existing
primary action, the empty-mode primary button is now disabled until the
stack name is non-empty, and the ModeRail disables itself while a Git
or docker-run create is in flight.
The async handlers (handleCreateStack, handleCreateStackFromGit,
handleConvertDockerRun, handleCreateStackFromDockerRun) and form-reset
helpers are unchanged; this PR is structural plus the segmented-control
redesign called out in the migration tracker as the D-4 risk note.
* test(e2e): align git-sources spec with new CreateStackDialog title
D-4 renamed the dialog title from "Create New Stack" to "New stack".
The shared openCreateStackDialog helper in git-sources.spec.ts was
still asserting the old literal, so three tests in the "Create stack
from Git" group failed at the helper's first assertion.
Update the assertion to match the shipped title and refresh the two
stale references in docs/features/stack-management.mdx so the docs
stay in sync with the UI copy.
* test(e2e): use dialog accessible name in openCreateStackDialog helper
The previous helper used getByText('New stack') which matched two
elements: the dialog title h2 and the sr-only description (which
starts "Create a new stack: empty, ..." and contains the substring).
Playwright fails with a strict mode violation.
Switch to getByRole('dialog', { name: 'New stack' }), which asserts
the dialog by its accessible name (provided by DialogTitle via the
Radix aria-labelledby wiring). One match, more precise, and
independent of any future description copy.
Replace raw Dialog/AlertDialog scaffolding in MfaEnrollDialog,
MfaDisableDialog, and MfaBackupCodesDialog with the shared Modal*
primitives introduced in D-1. Extract the duplicated BackupCodeTicket
component into a shared file consumed by both enroll and regen flows.
Fix a CSS grid auto-column blowout in Modal: the dialog's implicit auto
column was sizing to the step rail's min-content (492px), overflowing
the 448px max-w-md constraint and clipping right-side content. Adding
grid-cols-1 to the DialogContent override forces the column to use
minmax(0, 1fr), preventing any grid item from expanding the track past
available space. Also add min-w-0 to the TOTP secret code element so
its long monospaced string can truncate rather than drive column sizing.
Kicker prefixes updated to SECURITY · per the tracker convention.