feat(volumes): add read-only volume browser (#926)

* 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).
This commit is contained in:
Anso
2026-05-05 00:09:40 -04:00
committed by GitHub
parent 7e5dc2d9ea
commit 49d775c61f
11 changed files with 845 additions and 50 deletions
+24 -4
View File
@@ -18,7 +18,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { apiFetch } from '@/lib/api';
import { copyToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui/toast-store';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2, History } from 'lucide-react';
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2, History, FolderOpen } from 'lucide-react';
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
@@ -38,6 +38,7 @@ import { lazy, Suspense } from 'react';
import { ReclaimHero } from './resources/ReclaimHero';
import { FootprintTreemap } from './resources/FootprintTreemap';
import { ImageDetailsSheet } from './resources/ImageDetailsSheet';
import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet';
import { TabLanding, type TabLandingEntry } from './resources/TabLanding';
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
@@ -394,6 +395,7 @@ export default function ResourcesView() {
const [inspectNetwork, setInspectNetwork] = useState<NetworkInspectData | null>(null);
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
const [inspectImageId, setInspectImageId] = useState<string | null>(null);
const [browseVolume, setBrowseVolume] = useState<string | null>(null);
// Unmanaged container state
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
@@ -961,9 +963,23 @@ export default function ResourcesView() {
<TableCell className="hidden md:table-cell text-xs text-muted-foreground truncate max-w-[300px]">{vol.Mountpoint}</TableCell>
<TableCell><ManagedBadge status={vol.managedStatus} managedBy={vol.managedBy} /></TableCell>
<TableCell className="text-right">
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>}
<div className="flex items-center justify-end gap-1">
{isAdmin && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setBrowseVolume(vol.Name)}
title="Browse volume contents"
aria-label={`Browse ${vol.Name}`}
>
<FolderOpen className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>}
</div>
</TableCell>
</TableRow>
))}
@@ -1348,6 +1364,10 @@ export default function ResourcesView() {
{/* Image Details Sheet */}
<ImageDetailsSheet imageId={inspectImageId} onClose={() => setInspectImageId(null)} />
{/* Volume Browser Sheet */}
<VolumeBrowserSheet volumeName={browseVolume} onClose={() => setBrowseVolume(null)} />
{/* Network Inspect Sheet */}
<Sheet open={!!inspectNetwork} onOpenChange={open => !open && setInspectNetwork(null)}>
<SheetContent className="sm:max-w-lg">
+22 -10
View File
@@ -3,12 +3,14 @@ import type { ReactNode } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { listStackDirectory } from '@/lib/stackFilesApi';
import type { FileEntry } from '@/lib/stackFilesApi';
import { FileTreeNode } from './FileTreeNode';
interface FileTreeProps {
stackName: string;
/** Loads directory contents at `relPath` (use '' for the tree root). */
loadDir: (relPath: string) => Promise<FileEntry[]>;
/** Stable identity for the source. Changing it remounts the tree. */
sourceKey: string;
refreshKey?: number;
selectedPath: string;
onSelectFile: (relPath: string, entry: FileEntry) => void;
@@ -21,7 +23,8 @@ const ENV_NAMES = new Set(['.env']);
const MAX_ENTRIES = 500;
export function FileTree({
stackName,
loadDir,
sourceKey,
refreshKey,
selectedPath,
onSelectFile,
@@ -34,13 +37,18 @@ export function FileTree({
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const stackNameRef = useRef(stackName);
const sourceKeyRef = useRef(sourceKey);
const loadDirRef = useRef(loadDir);
// Always keep the latest loader in a ref so callers can pass a fresh
// function each render without re-triggering the root fetch effect below.
loadDirRef.current = loadDir;
useEffect(() => {
stackNameRef.current = stackName;
sourceKeyRef.current = sourceKey;
let cancelled = false;
listStackDirectory(stackName, '')
loadDirRef.current('')
.then((entries) => {
if (!cancelled) {
setRootEntries(entries);
@@ -59,7 +67,11 @@ export function FileTree({
return () => {
cancelled = true;
};
}, [stackName, refreshKey]);
// loadDir is intentionally read through loadDirRef to avoid refetch on
// identity-only changes from the parent (StackFileExplorer rebuilds the
// arrow on every render).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKey, refreshKey]);
function handleDirClick(dirRelPath: string) {
if (expandedDirs.has(dirRelPath)) {
@@ -78,10 +90,10 @@ export function FileTree({
setLoadingDirs((prev) => new Set(prev).add(dirRelPath));
const capturedStackName = stackName;
listStackDirectory(capturedStackName, dirRelPath)
const capturedSourceKey = sourceKey;
loadDirRef.current(dirRelPath)
.then((entries) => {
if (stackNameRef.current !== capturedStackName) return;
if (sourceKeyRef.current !== capturedSourceKey) return;
setDirContents((prev) => {
const next = new Map(prev);
next.set(dirRelPath, entries);
@@ -3,7 +3,7 @@ import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { downloadStackFile } from '@/lib/stackFilesApi';
import { downloadStackFile, listStackDirectory } from '@/lib/stackFilesApi';
import { FileTree } from './FileTree';
import { FileViewer } from './FileViewer';
import { FileUploadDropzone } from './FileUploadDropzone';
@@ -110,7 +110,8 @@ export function StackFileExplorer({
<div className="flex-1 min-h-0 overflow-hidden">
<FileTree
key={`${stackName}:${refreshKey}`}
stackName={stackName}
sourceKey={stackName}
loadDir={(p) => listStackDirectory(stackName, p)}
refreshKey={refreshKey}
selectedPath={selectedPath ?? ''}
onSelectFile={handleSelectFile}
@@ -10,10 +10,6 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { FileEntry } from '@/lib/stackFilesApi';
vi.mock('@/lib/stackFilesApi', () => ({
listStackDirectory: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
@@ -34,11 +30,8 @@ vi.mock('@/components/ui/skeleton', () => ({
Skeleton: () => <div data-testid="skeleton" />,
}));
import { listStackDirectory } from '@/lib/stackFilesApi';
import { FileTree } from '../FileTree';
const mockListDir = listStackDirectory as unknown as ReturnType<typeof vi.fn>;
function makeFile(name: string): FileEntry {
return { name, type: 'file', size: 100, mtime: 0, isProtected: false };
}
@@ -54,59 +47,65 @@ function fakeOk(entries: FileEntry[]): Promise<FileEntry[]> {
return Promise.resolve(entries);
}
const defaultProps = {
stackName: 'my-stack',
selectedPath: '',
onSelectFile: vi.fn(),
};
let onSelectFile: ReturnType<typeof vi.fn> & ((relPath: string, entry: FileEntry) => void);
let mockLoadDir: ReturnType<typeof vi.fn> & ((relPath: string) => Promise<FileEntry[]>);
function defaultProps() {
return {
sourceKey: 'my-stack',
loadDir: mockLoadDir,
selectedPath: '',
onSelectFile,
};
}
beforeEach(() => {
mockListDir.mockReset();
defaultProps.onSelectFile = vi.fn();
onSelectFile = vi.fn() as typeof onSelectFile;
mockLoadDir = vi.fn() as typeof mockLoadDir;
});
afterEach(() => vi.clearAllMocks());
describe('FileTree', () => {
it('fetches root entries on mount and renders them', async () => {
mockListDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
render(<FileTree {...defaultProps} />);
render(<FileTree {...defaultProps()} />);
await waitFor(() => expect(mockListDir).toHaveBeenCalledWith('my-stack', ''));
await waitFor(() => expect(mockLoadDir).toHaveBeenCalledWith(''));
expect(await screen.findByText('src')).toBeInTheDocument();
expect(screen.getByText('README.md')).toBeInTheDocument();
});
it('fetches subdirectory on first expand and shows children', async () => {
mockListDir
mockLoadDir
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
const user = userEvent.setup();
render(<FileTree {...defaultProps} />);
render(<FileTree {...defaultProps()} />);
await screen.findByText('src');
// One call so far: root fetch.
expect(mockListDir).toHaveBeenCalledTimes(1);
expect(mockLoadDir).toHaveBeenCalledTimes(1);
await user.click(screen.getByText('src'));
await waitFor(() => expect(mockListDir).toHaveBeenCalledTimes(2));
expect(mockListDir).toHaveBeenNthCalledWith(2, 'my-stack', 'src');
await waitFor(() => expect(mockLoadDir).toHaveBeenCalledTimes(2));
expect(mockLoadDir).toHaveBeenNthCalledWith(2, 'src');
expect(await screen.findByText('index.ts')).toBeInTheDocument();
expect(screen.getByText('app.ts')).toBeInTheDocument();
});
it('collapses on second click (no additional fetch)', async () => {
mockListDir
mockLoadDir
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
const user = userEvent.setup();
render(<FileTree {...defaultProps} />);
render(<FileTree {...defaultProps()} />);
await screen.findByText('src');
@@ -114,23 +113,23 @@ describe('FileTree', () => {
await user.click(screen.getByText('src'));
await screen.findByText('index.ts');
const callsAfterExpand = mockListDir.mock.calls.length;
const callsAfterExpand = mockLoadDir.mock.calls.length;
// Collapse.
await user.click(screen.getByText('src'));
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
// No extra fetch should have happened.
expect(mockListDir).toHaveBeenCalledTimes(callsAfterExpand);
expect(mockLoadDir).toHaveBeenCalledTimes(callsAfterExpand);
});
it('re-expands from cache on third click (no second fetch for that dir)', async () => {
mockListDir
mockLoadDir
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
const user = userEvent.setup();
render(<FileTree {...defaultProps} />);
render(<FileTree {...defaultProps()} />);
await screen.findByText('src');
@@ -142,28 +141,28 @@ describe('FileTree', () => {
await user.click(screen.getByText('src'));
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
const callsAfterCollapse = mockListDir.mock.calls.length;
const callsAfterCollapse = mockLoadDir.mock.calls.length;
// Third click: re-expand from cache.
await user.click(screen.getByText('src'));
await screen.findByText('index.ts');
// Fetch count must not have increased.
expect(mockListDir).toHaveBeenCalledTimes(callsAfterCollapse);
expect(mockLoadDir).toHaveBeenCalledTimes(callsAfterCollapse);
});
it('shows error message when root fetch fails', async () => {
mockListDir.mockRejectedValue(new Error('Network error'));
mockLoadDir.mockRejectedValue(new Error('Network error'));
render(<FileTree {...defaultProps} />);
render(<FileTree {...defaultProps()} />);
expect(await screen.findByText('Network error')).toBeInTheDocument();
});
it('shows empty state when root returns no entries', async () => {
mockListDir.mockReturnValue(fakeOk([]));
mockLoadDir.mockReturnValue(fakeOk([]));
render(<FileTree {...defaultProps} />);
render(<FileTree {...defaultProps()} />);
expect(await screen.findByText(/empty folder/i)).toBeInTheDocument();
});
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { HardDrive, RefreshCw } from 'lucide-react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import { FileTree } from '@/components/files/FileTree';
import type { FileEntry } from '@/lib/stackFilesApi';
import { listVolumeDirectory, readVolumeFile } from '@/lib/volumeApi';
import type { VolumeFileResult } from '@/lib/volumeApi';
import { formatBytes } from '@/lib/utils';
interface VolumeBrowserSheetProps {
volumeName: string | null;
onClose: () => void;
}
export function VolumeBrowserSheet({ volumeName, onClose }: VolumeBrowserSheetProps) {
const [refreshKey, setRefreshKey] = useState(0);
const [selectedPath, setSelectedPath] = useState<string>('');
const [fileLoading, setFileLoading] = useState(false);
const [fileResult, setFileResult] = useState<VolumeFileResult | null>(null);
// Generation counter so a slow read for a stale (volume, path) selection
// cannot stomp the visible result after the user has moved on.
const readGenerationRef = useRef(0);
useEffect(() => {
// Cancel any in-flight read when the volume context changes.
readGenerationRef.current += 1;
}, [volumeName]);
const handleSelectFile = useCallback(async (relPath: string) => {
if (!volumeName) return;
const generation = ++readGenerationRef.current;
const targetVolume = volumeName;
setSelectedPath(relPath);
setFileLoading(true);
setFileResult(null);
try {
const result = await readVolumeFile(targetVolume, relPath);
if (readGenerationRef.current !== generation) return;
setFileResult(result);
} catch (err: unknown) {
if (readGenerationRef.current !== generation) return;
const msg = err instanceof Error ? err.message : 'Failed to read file.';
toast.error(msg);
} finally {
if (readGenerationRef.current === generation) setFileLoading(false);
}
}, [volumeName]);
const handleClose = (open: boolean) => {
if (!open) {
setSelectedPath('');
setFileResult(null);
setFileLoading(false);
onClose();
}
};
const loadDir = useCallback(
(relPath: string) => {
if (!volumeName) return Promise.resolve<FileEntry[]>([]);
return listVolumeDirectory(volumeName, relPath);
},
[volumeName]
);
return (
<Sheet open={!!volumeName} onOpenChange={handleClose}>
<SheetContent className="sm:max-w-3xl">
<SheetHeader className="pr-10">
<SheetTitle className="flex items-center gap-2 min-w-0">
<HardDrive className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-mono text-sm truncate">{volumeName}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 ml-auto shrink-0"
onClick={() => setRefreshKey((k) => k + 1)}
title="Refresh tree"
aria-label="Refresh tree"
>
<RefreshCw className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</SheetTitle>
</SheetHeader>
{volumeName && (
<div className="grid grid-cols-[260px_1fr] gap-3 mt-4 h-[calc(100vh-180px)]">
<div className="rounded-md border border-card-border bg-card overflow-hidden">
<FileTree
key={`${volumeName}:${refreshKey}`}
sourceKey={volumeName}
loadDir={loadDir}
refreshKey={refreshKey}
selectedPath={selectedPath}
onSelectFile={handleSelectFile}
/>
</div>
<div className="rounded-md border border-card-border bg-card overflow-hidden flex flex-col">
{!selectedPath && (
<div className="flex-1 flex items-center justify-center p-6 text-xs text-muted-foreground italic">
Select a file to preview.
</div>
)}
{selectedPath && fileLoading && (
<div className="p-3 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
)}
{selectedPath && !fileLoading && fileResult && (
<FileResultPanel path={selectedPath} result={fileResult} />
)}
</div>
</div>
)}
<p className="mt-3 text-[11px] text-muted-foreground">
File reads are recorded in the audit log.
</p>
</SheetContent>
</Sheet>
);
}
function FileResultPanel({ path, result }: { path: string; result: VolumeFileResult }) {
const decoded = result.binary ? base64ToHex(result.content) : result.content;
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b border-card-border">
<span className="font-mono text-[11px] text-muted-foreground truncate" title={path}>{path}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatBytes(result.size)}{result.binary ? ' · binary' : ''}{result.truncated ? ' · truncated' : ''}
</span>
</div>
{result.truncated && (
<div className="px-3 py-1.5 text-[11px] text-warning bg-warning/10 border-b border-card-border">
Showing first {formatBytes(5 * 1024 * 1024)}. Larger files cannot be downloaded from this view.
</div>
)}
<pre className="flex-1 overflow-auto p-3 font-mono text-[11px] whitespace-pre-wrap break-all leading-relaxed">
{decoded}
</pre>
</div>
);
}
function base64ToHex(b64: string): string {
try {
const binary = atob(b64);
const out: string[] = [];
for (let i = 0; i < binary.length; i += 16) {
const offset = i.toString(16).padStart(8, '0');
const chunk = binary.slice(i, i + 16);
const hex = Array.from(chunk).map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
const ascii = Array.from(chunk).map((c) => {
const code = c.charCodeAt(0);
return code >= 32 && code <= 126 ? c : '.';
}).join('');
out.push(`${offset} ${hex.padEnd(48, ' ')} ${ascii}`);
}
return out.join('\n');
} catch {
return '(unable to decode binary content)';
}
}
+47
View File
@@ -0,0 +1,47 @@
import { apiFetch } from './api';
import { parseApiError } from './stackFilesApi';
import type { FileEntry } from './stackFilesApi';
export interface VolumeFileResult {
content: string;
encoding: 'utf-8' | 'base64';
binary: boolean;
truncated: boolean;
size: number;
mime: string;
}
export interface VolumeStat extends FileEntry {
symlinkTarget?: string;
}
function volumeUrl(volumeName: string, suffix: string): string {
return `/volumes/${encodeURIComponent(volumeName)}${suffix}`;
}
export async function listVolumeDirectory(
volumeName: string,
relPath: string
): Promise<FileEntry[]> {
const res = await apiFetch(volumeUrl(volumeName, `/list?path=${encodeURIComponent(relPath)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileEntry[]>;
}
export async function readVolumeFile(
volumeName: string,
relPath: string
): Promise<VolumeFileResult> {
const res = await apiFetch(volumeUrl(volumeName, `/read?path=${encodeURIComponent(relPath)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<VolumeFileResult>;
}
export async function statVolumePath(
volumeName: string,
relPath: string
): Promise<VolumeStat> {
const res = await apiFetch(volumeUrl(volumeName, `/stat?path=${encodeURIComponent(relPath)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<VolumeStat>;
}