* feat(resources): reclaim banner controls and accurate reclaim math
Make the Resources Hub reclaim banner match what it advertises and give
operators control over when it appears.
- "Review & prune" now reclaims every category the banner lists (unused
images, stopped containers, and dangling volumes) instead of images
only, so the banner clears in one action. Pruning runs volumes first,
while stopped containers still reference their named volumes, so a
stopped stack's data is never cascaded into deletion.
- Add a "Show reclaimable-space banner" toggle under Settings, System,
Docker hygiene (on by default, per node) and a dismiss control on the
banner that snoozes it until the reclaimable total grows again.
- Fix the reclaimable-space math: count only containers a prune can
actually remove (created, exited, dead) and size them by their writable
layer, so a small, un-prunable remainder no longer keeps the banner up.
* fix(resources): show the reclaim banner when the settings fetch fails
A failed or empty /settings load left the banner's enabled flag at the
previously active node's value, so switching from a node with the banner
turned off to a node whose /settings errored kept the new node's banner
hidden. Set the flag unconditionally after the staleness guard so a
failed fetch falls back to the default-on state for the current node.
The two landing cards above the volumes table did not earn their space
and made the Volumes view feel cluttered. Drop the cards, the unused
TabLanding component, and the helpers that only fed it.
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.
* 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.
Replaces the stacked bar + legend with a reclaim-first layout: an amber
hero banner surfaces the total reclaimable bytes and breakdown (unused
images, stopped containers, dangling volumes) with a one-click review &
prune CTA; a three-tile treemap replaces the stacked bar with proportional
areas for Sencho-managed, External, and Reclaimable; and the Volumes tab
gets a two-card landing highlighting the largest volumes by size and
recently changed ones.