mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Coverage for VolumeBrowserService pure helpers: path traversal sanitization,
|
||||
* volume-name validation, and binary detection. The Docker-facing exec path
|
||||
* is exercised in manual E2E only — mocking dockerode.run reliably is not
|
||||
* worth the brittleness for this PR.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
sanitizeRelPath,
|
||||
isValidVolumeName,
|
||||
isBinaryBuffer,
|
||||
PathTraversalError,
|
||||
} from '../services/VolumeBrowserService';
|
||||
|
||||
describe('sanitizeRelPath', () => {
|
||||
it('returns empty string for the volume root', () => {
|
||||
expect(sanitizeRelPath('')).toBe('');
|
||||
expect(sanitizeRelPath('.')).toBe('');
|
||||
expect(sanitizeRelPath('/')).toBe('');
|
||||
});
|
||||
|
||||
it('strips leading slashes', () => {
|
||||
expect(sanitizeRelPath('/etc/foo')).toBe('etc/foo');
|
||||
expect(sanitizeRelPath('//etc/foo')).toBe('etc/foo');
|
||||
});
|
||||
|
||||
it('preserves nested relative paths', () => {
|
||||
expect(sanitizeRelPath('a/b/c.txt')).toBe('a/b/c.txt');
|
||||
});
|
||||
|
||||
it('rejects parent-escape segments', () => {
|
||||
expect(() => sanitizeRelPath('../etc/passwd')).toThrow(PathTraversalError);
|
||||
expect(() => sanitizeRelPath('foo/../../etc/passwd')).toThrow(PathTraversalError);
|
||||
expect(() => sanitizeRelPath('..')).toThrow(PathTraversalError);
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => sanitizeRelPath('foo\0.txt')).toThrow(PathTraversalError);
|
||||
});
|
||||
|
||||
it('rejects oversize paths', () => {
|
||||
const huge = 'a/'.repeat(700);
|
||||
expect(() => sanitizeRelPath(huge)).toThrow(PathTraversalError);
|
||||
});
|
||||
|
||||
it('rejects non-string input', () => {
|
||||
expect(() => sanitizeRelPath(undefined as unknown as string)).toThrow(PathTraversalError);
|
||||
expect(() => sanitizeRelPath(null as unknown as string)).toThrow(PathTraversalError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidVolumeName', () => {
|
||||
it('accepts typical Docker volume names', () => {
|
||||
expect(isValidVolumeName('my-stack_data')).toBe(true);
|
||||
expect(isValidVolumeName('volume.with.dots')).toBe(true);
|
||||
expect(isValidVolumeName('a')).toBe(true);
|
||||
expect(isValidVolumeName('Vol_123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects names with disallowed characters', () => {
|
||||
expect(isValidVolumeName('vol/with/slashes')).toBe(false);
|
||||
expect(isValidVolumeName('vol with space')).toBe(false);
|
||||
expect(isValidVolumeName('vol;rm -rf')).toBe(false);
|
||||
expect(isValidVolumeName('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects names that start with non-alphanumeric', () => {
|
||||
expect(isValidVolumeName('-leading-dash')).toBe(false);
|
||||
expect(isValidVolumeName('.leading-dot')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects oversize names', () => {
|
||||
expect(isValidVolumeName('a'.repeat(256))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBinaryBuffer', () => {
|
||||
it('flags buffers containing null bytes as binary', () => {
|
||||
expect(isBinaryBuffer(Buffer.from('hello\0world'))).toBe(true);
|
||||
expect(isBinaryBuffer(Buffer.from([0xff, 0x00, 0x42]))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats plain UTF-8 text as non-binary', () => {
|
||||
expect(isBinaryBuffer(Buffer.from('hello world\nlet me see\n'))).toBe(false);
|
||||
expect(isBinaryBuffer(Buffer.from('héllo wörld'))).toBe(false);
|
||||
});
|
||||
|
||||
it('only inspects the first 8 KB', () => {
|
||||
// Pure text in the first 8KB; null byte after.
|
||||
const head = Buffer.alloc(8192, 0x41);
|
||||
const tail = Buffer.from([0]);
|
||||
const buf = Buffer.concat([head, tail]);
|
||||
expect(isBinaryBuffer(buf)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles an empty buffer', () => {
|
||||
expect(isBinaryBuffer(Buffer.alloc(0))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import { consoleRouter } from './routes/console';
|
||||
import { ssoConfigRouter } from './routes/ssoConfig';
|
||||
import { registriesRouter } from './routes/registries';
|
||||
import { systemMaintenanceRouter } from './routes/systemMaintenance';
|
||||
import { volumesRouter } from './routes/volumes';
|
||||
import { templatesRouter } from './routes/templates';
|
||||
import { securityRouter } from './routes/security';
|
||||
import { dashboardRouter } from './routes/dashboard';
|
||||
@@ -118,6 +119,7 @@ app.use('/api/system', consoleRouter);
|
||||
app.use('/api/sso/config', ssoConfigRouter);
|
||||
app.use('/api/registries', registriesRouter);
|
||||
app.use('/api/system', systemMaintenanceRouter);
|
||||
app.use('/api/volumes', volumesRouter);
|
||||
app.use('/api/templates', templatesRouter);
|
||||
app.use('/api/security', securityRouter);
|
||||
app.use('/api/containers', containersRouter);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { VolumeBrowserService, isValidVolumeName, PathTraversalError, VolumeNotFoundError, HelperImageError, ExecError } from '../services/VolumeBrowserService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export const volumesRouter = Router();
|
||||
|
||||
function readPathParam(req: Request): string {
|
||||
const raw = req.query.path;
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function mapServiceError(error: unknown, res: Response, fallback: string): Response {
|
||||
if (error instanceof PathTraversalError) return res.status(400).json({ error: error.message });
|
||||
if (error instanceof VolumeNotFoundError) return res.status(404).json({ error: error.message });
|
||||
if (error instanceof HelperImageError) return res.status(503).json({ error: error.message });
|
||||
if (error instanceof ExecError) return res.status(error.status).json({ error: error.message });
|
||||
console.error(`[Volumes] ${fallback}:`, error);
|
||||
return res.status(500).json({ error: fallback });
|
||||
}
|
||||
|
||||
volumesRouter.get('/:name/list', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const name = req.params.name as string;
|
||||
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
|
||||
const path = readPathParam(req);
|
||||
const entries = await VolumeBrowserService.getInstance(req.nodeId).listDir(name, path);
|
||||
res.json(entries);
|
||||
} catch (error: unknown) {
|
||||
mapServiceError(error, res, 'Failed to list volume directory');
|
||||
}
|
||||
});
|
||||
|
||||
volumesRouter.get('/:name/stat', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const name = req.params.name as string;
|
||||
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
|
||||
const path = readPathParam(req);
|
||||
const meta = await VolumeBrowserService.getInstance(req.nodeId).stat(name, path);
|
||||
res.json(meta);
|
||||
} catch (error: unknown) {
|
||||
mapServiceError(error, res, 'Failed to stat volume path');
|
||||
}
|
||||
});
|
||||
|
||||
volumesRouter.get('/:name/read', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const name = req.params.name as string;
|
||||
const requestPath = readPathParam(req);
|
||||
let outcome: 'success' | 'error' = 'error';
|
||||
try {
|
||||
if (!isValidVolumeName(name)) return res.status(400).json({ error: 'Invalid volume name' });
|
||||
const result = await VolumeBrowserService.getInstance(req.nodeId).readFile(name, requestPath);
|
||||
outcome = 'success';
|
||||
res.json(result);
|
||||
} catch (error: unknown) {
|
||||
mapServiceError(error, res, 'Failed to read volume file');
|
||||
} finally {
|
||||
try {
|
||||
DatabaseService.getInstance().insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: req.user?.username ?? 'unknown',
|
||||
method: 'GET',
|
||||
path: req.path,
|
||||
status_code: res.statusCode,
|
||||
node_id: req.nodeId ?? null,
|
||||
ip_address: req.ip ?? 'unknown',
|
||||
summary: `${outcome === 'success' ? 'Read' : 'Failed read of'} volume file: ${sanitizeForLog(name)}:${sanitizeForLog(requestPath || '/')}`,
|
||||
});
|
||||
} catch (auditErr) {
|
||||
console.error('[Volumes] Audit log insert failed:', auditErr);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Writable } from 'stream';
|
||||
import path from 'path';
|
||||
import DockerController from './DockerController';
|
||||
|
||||
const HELPER_IMAGE = 'alpine:3.20';
|
||||
const VOLUME_MOUNT = '/v';
|
||||
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const EXEC_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Portable shell scripts that work with BusyBox sh + stat (Alpine) and GNU
|
||||
// coreutils alike. The user-supplied relative path arrives as $1; we cd into
|
||||
// it before iterating, so user input never lands as an argv element to a
|
||||
// command that might interpret it as a flag.
|
||||
const LIST_SCRIPT = `set -e
|
||||
cd -- "$1" 2>/dev/null || { echo "cd: $1: No such file or directory" >&2; exit 1; }
|
||||
for entry in * .[!.]* ..?*; do
|
||||
[ -e "$entry" ] || [ -L "$entry" ] || continue
|
||||
if [ -L "$entry" ]; then t=l; link=$(readlink -- "$entry" 2>/dev/null || echo "")
|
||||
elif [ -d "$entry" ]; then t=d; link=""
|
||||
elif [ -f "$entry" ]; then t=f; link=""
|
||||
else t=o; link=""
|
||||
fi
|
||||
size=$(stat -c '%s' -- "$entry" 2>/dev/null || echo 0)
|
||||
mtime=$(stat -c '%Y' -- "$entry" 2>/dev/null || echo 0)
|
||||
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$entry" "$link"
|
||||
done`;
|
||||
|
||||
const STAT_SCRIPT = `set -e
|
||||
target="$1"
|
||||
[ -e "$target" ] || [ -L "$target" ] || { echo "cannot access $target" >&2; exit 1; }
|
||||
if [ -L "$target" ]; then t=l; link=$(readlink -- "$target" 2>/dev/null || echo "")
|
||||
elif [ -d "$target" ]; then t=d; link=""
|
||||
elif [ -f "$target" ]; then t=f; link=""
|
||||
else t=o; link=""
|
||||
fi
|
||||
size=$(stat -c '%s' -- "$target" 2>/dev/null || echo 0)
|
||||
mtime=$(stat -c '%Y' -- "$target" 2>/dev/null || echo 0)
|
||||
name=$(basename -- "$target")
|
||||
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$name" "$link"`;
|
||||
|
||||
export interface VolumeEntry {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink' | 'other';
|
||||
size: number;
|
||||
mtime: number;
|
||||
isProtected: boolean;
|
||||
symlinkTarget?: string;
|
||||
}
|
||||
|
||||
export interface VolumeFileResult {
|
||||
content: string;
|
||||
encoding: 'utf-8' | 'base64';
|
||||
binary: boolean;
|
||||
truncated: boolean;
|
||||
size: number;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
export type VolumeStat = VolumeEntry;
|
||||
|
||||
export class PathTraversalError extends Error {
|
||||
status = 400;
|
||||
constructor() { super('Path escapes volume root'); this.name = 'PathTraversalError'; }
|
||||
}
|
||||
|
||||
export class VolumeNotFoundError extends Error {
|
||||
status = 404;
|
||||
constructor(name: string) { super(`Volume '${name}' not found`); this.name = 'VolumeNotFoundError'; }
|
||||
}
|
||||
|
||||
export class HelperImageError extends Error {
|
||||
status = 503;
|
||||
constructor(reason: string) { super(`Volume browser helper unavailable: ${reason}`); this.name = 'HelperImageError'; }
|
||||
}
|
||||
|
||||
export class ExecError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 500) { super(message); this.status = status; this.name = 'ExecError'; }
|
||||
}
|
||||
|
||||
const helperImageReady = new Map<number, boolean>();
|
||||
|
||||
export class VolumeBrowserService {
|
||||
private nodeId: number;
|
||||
|
||||
private constructor(nodeId: number) { this.nodeId = nodeId; }
|
||||
|
||||
static getInstance(nodeId?: number): VolumeBrowserService {
|
||||
return new VolumeBrowserService(nodeId ?? 1);
|
||||
}
|
||||
|
||||
async listDir(volumeName: string, relPath: string): Promise<VolumeEntry[]> {
|
||||
const safe = sanitizeRelPath(relPath);
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
|
||||
// Portable across BusyBox (Alpine) and GNU coreutils. Lists each
|
||||
// direct child with a tab-separated row: type<TAB>size<TAB>mtime<TAB>
|
||||
// name<TAB>symlinkTarget. We chdir to /v/<safe> first so user input is
|
||||
// never an argv element passed to find/stat.
|
||||
const script = LIST_SCRIPT;
|
||||
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
|
||||
'sh', '-c', script, 'sh', `./${safe || ''}`,
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/No such file or directory|cannot access|cd:.*?:/i.test(msg)) throw new ExecError('Path not found', 404);
|
||||
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
|
||||
throw new ExecError(`Listing failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
|
||||
const entries: VolumeEntry[] = [];
|
||||
const lines = stdout.toString('utf-8').split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 4) continue;
|
||||
const [shType, sizeStr, mtimeStr, name, link = ''] = parts;
|
||||
if (!name) continue;
|
||||
const type: VolumeEntry['type'] =
|
||||
shType === 'd' ? 'directory'
|
||||
: shType === 'f' ? 'file'
|
||||
: shType === 'l' ? 'symlink'
|
||||
: 'other';
|
||||
entries.push({
|
||||
name,
|
||||
type,
|
||||
size: Number(sizeStr) || 0,
|
||||
mtime: Math.floor(Number(mtimeStr) || 0),
|
||||
isProtected: false,
|
||||
...(type === 'symlink' && link ? { symlinkTarget: link } : {}),
|
||||
});
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
async stat(volumeName: string, relPath: string): Promise<VolumeStat> {
|
||||
const safe = sanitizeRelPath(relPath);
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
|
||||
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
|
||||
'sh', '-c', STAT_SCRIPT, 'sh', `./${safe || ''}`,
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/No such file or directory|cannot access/i.test(msg)) throw new ExecError('Path not found', 404);
|
||||
throw new ExecError(`Stat failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
const line = stdout.toString('utf-8').split('\n').filter(Boolean)[0] ?? '';
|
||||
const parts = line.split('\t');
|
||||
const [shType, sizeStr, mtimeStr, name, link = ''] = parts;
|
||||
const type: VolumeEntry['type'] =
|
||||
shType === 'd' ? 'directory'
|
||||
: shType === 'f' ? 'file'
|
||||
: shType === 'l' ? 'symlink'
|
||||
: 'other';
|
||||
return {
|
||||
name: name || path.posix.basename(safe || '/'),
|
||||
type,
|
||||
size: Number(sizeStr) || 0,
|
||||
mtime: Math.floor(Number(mtimeStr) || 0),
|
||||
isProtected: false,
|
||||
...(type === 'symlink' && link ? { symlinkTarget: link } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(volumeName: string, relPath: string, opts: { maxBytes?: number } = {}): Promise<VolumeFileResult> {
|
||||
const safe = sanitizeRelPath(relPath);
|
||||
if (!safe) throw new ExecError('Cannot read volume root', 400);
|
||||
const maxBytes = Math.max(1, Math.min(opts.maxBytes ?? DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES));
|
||||
await this.assertVolumeExists(volumeName);
|
||||
await this.ensureHelperImage();
|
||||
|
||||
const meta = await this.stat(volumeName, safe);
|
||||
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
|
||||
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
|
||||
|
||||
// Read up to maxBytes+1 to detect truncation precisely. The path is
|
||||
// passed as $1 (an argv element, never concatenated) and read with
|
||||
// head -c -- "$1" so a leading-dash filename is never parsed as a flag.
|
||||
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
|
||||
'sh', '-c',
|
||||
`head -c ${maxBytes + 1} -- "$1"`,
|
||||
'sh', `./${safe}`,
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
const msg = stderr.toString('utf-8').trim();
|
||||
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
|
||||
throw new ExecError(`Read failed: ${msg.substring(0, 200) || 'unknown error'}`);
|
||||
}
|
||||
|
||||
const truncated = stdout.length > maxBytes;
|
||||
const buf = truncated ? stdout.subarray(0, maxBytes) : stdout;
|
||||
const binary = isBinaryBuffer(buf);
|
||||
const mime = binary ? 'application/octet-stream' : 'text/plain';
|
||||
|
||||
return {
|
||||
content: binary ? buf.toString('base64') : buf.toString('utf-8'),
|
||||
encoding: binary ? 'base64' : 'utf-8',
|
||||
binary,
|
||||
truncated,
|
||||
size: meta.size,
|
||||
mime,
|
||||
};
|
||||
}
|
||||
|
||||
// --- internals -----------------------------------------------------------
|
||||
|
||||
private async assertVolumeExists(volumeName: string): Promise<void> {
|
||||
if (!isValidVolumeName(volumeName)) throw new ExecError('Invalid volume name', 400);
|
||||
const docker = DockerController.getInstance(this.nodeId).getDocker();
|
||||
try {
|
||||
await docker.getVolume(volumeName).inspect();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { statusCode?: number; message?: string };
|
||||
if (e.statusCode === 404 || /no such volume/i.test(e.message ?? '')) {
|
||||
throw new VolumeNotFoundError(volumeName);
|
||||
}
|
||||
throw new ExecError(`Volume inspect failed: ${e.message ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureHelperImage(): Promise<void> {
|
||||
if (helperImageReady.get(this.nodeId)) return;
|
||||
const docker = DockerController.getInstance(this.nodeId).getDocker();
|
||||
try {
|
||||
await docker.getImage(HELPER_IMAGE).inspect();
|
||||
helperImageReady.set(this.nodeId, true);
|
||||
return;
|
||||
} catch { /* not present, pull it */ }
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.pull(HELPER_IMAGE, (err: Error | null, stream: NodeJS.ReadableStream | null) => {
|
||||
if (err || !stream) { reject(err ?? new Error('pull stream missing')); return; }
|
||||
docker.modem.followProgress(stream, (finishErr: Error | null) => {
|
||||
if (finishErr) reject(finishErr); else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
helperImageReady.set(this.nodeId, true);
|
||||
} catch (err) {
|
||||
throw new HelperImageError((err as Error).message ?? 'pull failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async runHelper(volumeName: string, cmd: string[]): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
|
||||
const docker = DockerController.getInstance(this.nodeId).getDocker();
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const stdoutStream = new Writable({ write(chunk, _enc, cb) { stdoutChunks.push(chunk); cb(); } });
|
||||
const stderrStream = new Writable({ write(chunk, _enc, cb) { stderrChunks.push(chunk); cb(); } });
|
||||
|
||||
// Manual lifecycle (create -> attach -> start -> wait -> remove). Using
|
||||
// dockerode's docker.run() with AutoRemove races: Docker can delete the
|
||||
// container before run()'s internal wait() callback fires, surfacing as
|
||||
// a 404 "no such container" from docker-modem.
|
||||
const container = await docker.createContainer({
|
||||
Image: HELPER_IMAGE,
|
||||
Cmd: cmd,
|
||||
Tty: false,
|
||||
User: '65534:65534',
|
||||
WorkingDir: VOLUME_MOUNT,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
HostConfig: {
|
||||
ReadonlyRootfs: true,
|
||||
NetworkMode: 'none',
|
||||
CapDrop: ['ALL'],
|
||||
Privileged: false,
|
||||
SecurityOpt: ['no-new-privileges:true'],
|
||||
PidsLimit: 64,
|
||||
Memory: 128 * 1024 * 1024,
|
||||
Mounts: [{
|
||||
Type: 'volume',
|
||||
Source: volumeName,
|
||||
Target: VOLUME_MOUNT,
|
||||
ReadOnly: true,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new ExecError('Helper exec timed out', 504)), EXEC_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
const runPromise = (async () => {
|
||||
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
|
||||
const streamEnded = new Promise<void>((resolve) => {
|
||||
stream.once('end', () => resolve());
|
||||
stream.once('close', () => resolve());
|
||||
});
|
||||
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
||||
await container.start();
|
||||
const exitInfo = await container.wait();
|
||||
// Wait for the attach stream to finish flushing demuxed output.
|
||||
await streamEnded;
|
||||
return {
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr: Buffer.concat(stderrChunks),
|
||||
exitCode: typeof exitInfo?.StatusCode === 'number' ? exitInfo.StatusCode : 0,
|
||||
};
|
||||
})();
|
||||
|
||||
try {
|
||||
return await Promise.race([runPromise, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
// Always tear down the helper, regardless of how runPromise resolved.
|
||||
try { await container.remove({ force: true }); } catch { /* container may have been killed by timeout already */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers -----------------------------------------------------------------
|
||||
|
||||
const VOLUME_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$/;
|
||||
export function isValidVolumeName(name: string): boolean {
|
||||
return typeof name === 'string' && VOLUME_NAME_RE.test(name);
|
||||
}
|
||||
|
||||
export function sanitizeRelPath(relPath: string): string {
|
||||
if (typeof relPath !== 'string') throw new PathTraversalError();
|
||||
if (relPath.length > 1024) throw new PathTraversalError();
|
||||
if (relPath.includes('\0')) throw new PathTraversalError();
|
||||
// Normalize away leading slashes; reject absolute and parent-escape.
|
||||
const p = relPath.replace(/^\/+/, '');
|
||||
if (p === '' || p === '.') return '';
|
||||
if (p.split('/').some((seg) => seg === '..')) throw new PathTraversalError();
|
||||
// posix-resolve and re-check that it stays under the mount root.
|
||||
const resolved = path.posix.resolve(VOLUME_MOUNT, p);
|
||||
if (resolved !== VOLUME_MOUNT && !resolved.startsWith(`${VOLUME_MOUNT}/`)) {
|
||||
throw new PathTraversalError();
|
||||
}
|
||||
// Return the trailing portion (without the /v prefix) so callers can re-join.
|
||||
return resolved === VOLUME_MOUNT ? '' : resolved.slice(VOLUME_MOUNT.length + 1);
|
||||
}
|
||||
|
||||
export function isBinaryBuffer(buf: Buffer): boolean {
|
||||
const limit = Math.min(buf.length, 8192);
|
||||
for (let i = 0; i < limit; i++) {
|
||||
if (buf[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -79,6 +79,20 @@ When any volumes are present, a two-card landing strip above the table highlight
|
||||
Deleting a volume is permanent. Any data stored in it will be lost. Always back up important volume data before pruning.
|
||||
</Warning>
|
||||
|
||||
#### Browse volume contents
|
||||
|
||||
Click the folder icon on any volume row (admin only) to open a read-only browser for that volume. The panel shows a directory tree on the left and a file viewer on the right.
|
||||
|
||||
- **Read only.** No edits, no writes, no deletions. The browser launches a short-lived helper container with the volume mounted read-only.
|
||||
- **Size cap.** Files are read up to 5 MB. Larger files show a truncation banner.
|
||||
- **Binary files** are displayed as a hex view. Plain text is shown directly.
|
||||
- **Symlinks** are listed but never followed. Reading a symlink path returns an error.
|
||||
- **Audit log.** Every file read is recorded in the audit log, including failed reads, with the volume and path. Use this for compliance and incident review.
|
||||
|
||||
<Warning>
|
||||
Volumes commonly contain database files, password hashes, certificates, and other secrets. Treat the volume browser as a high-power admin tool.
|
||||
</Warning>
|
||||
|
||||
### Networks
|
||||
|
||||
Lists all Docker networks. Columns: ID, name, driver, scope (`local`, `global`, `swarm`), and managed status.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user