feat: local volume explore file system

This commit is contained in:
Nicolas Meienberger
2025-11-08 11:00:18 +01:00
parent 4aeebea5b2
commit 5b4b571581
15 changed files with 409 additions and 24 deletions

View File

@@ -5,10 +5,26 @@ import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import type { VolumeBackend } from "../backend";
const mount = async (_config: BackendConfig, path: string) => {
logger.info("Mounting directory volume...", path);
await fs.mkdir(path, { recursive: true });
return { status: BACKEND_STATUS.mounted };
const mount = async (config: BackendConfig, _volumePath: string) => {
if (config.backend !== "directory") {
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
}
logger.info("Mounting directory volume from:", config.path);
try {
await fs.access(config.path);
const stats = await fs.stat(config.path);
if (!stats.isDirectory()) {
return { status: BACKEND_STATUS.error, error: "Path is not a directory" };
}
return { status: BACKEND_STATUS.mounted };
} catch (error) {
logger.error("Failed to mount directory volume:", error);
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const unmount = async () => {
@@ -16,12 +32,16 @@ const unmount = async () => {
return { status: BACKEND_STATUS.unmounted };
};
const checkHealth = async (path: string) => {
const checkHealth = async (config: BackendConfig) => {
if (config.backend !== "directory") {
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
}
try {
await fs.access(path);
await fs.access(config.path);
// Try to create a temporary file to ensure write access
const tempFilePath = npath.join(path, `.healthcheck-${Date.now()}`);
const tempFilePath = npath.join(config.path, `.healthcheck-${Date.now()}`);
await fs.writeFile(tempFilePath, "healthcheck");
await fs.unlink(tempFilePath);
@@ -32,8 +52,8 @@ const checkHealth = async (path: string) => {
}
};
export const makeDirectoryBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({
mount: () => mount(config, volumePath),
unmount,
checkHealth: () => checkHealth(path),
checkHealth: () => checkHealth(config),
});

View File

@@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: string) => {
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path, config.readOnly);
const { status } = await checkHealth(path, config.readOnly ?? false);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
@@ -117,5 +117,5 @@ const checkHealth = async (path: string, readOnly: boolean) => {
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: string) => {
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path, config.readOnly);
const { status } = await checkHealth(path, config.readOnly ?? false);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
@@ -130,5 +130,5 @@ const checkHealth = async (path: string, readOnly: boolean) => {
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -26,7 +26,7 @@ const mount = async (config: BackendConfig, path: string) => {
return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(path, config.readOnly);
const { status } = await checkHealth(path, config.readOnly ?? false);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
@@ -164,5 +164,5 @@ const checkHealth = async (path: string, readOnly: boolean) => {
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -278,13 +278,12 @@ export const restoreSnapshotDto = describeRoute({
export const doctorStepSchema = type({
step: "string",
success: "boolean",
output: "string?",
error: "string?",
output: "string | null",
error: "string | null",
});
export const doctorRepositoryResponse = type({
success: "boolean",
message: "string",
steps: doctorStepSchema.array(),
});

View File

@@ -21,6 +21,8 @@ import {
type ListContainersDto,
type UpdateVolumeDto,
type ListFilesDto,
browseFilesystemDto,
type BrowseFilesystemDto,
} from "./volume.dto";
import { volumeService } from "./volume.service";
import { getVolumePath } from "./helpers";
@@ -121,4 +123,15 @@ export const volumeController = new Hono()
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
return c.json<ListFilesDto>(response, 200);
})
.get("/filesystem/browse", browseFilesystemDto, async (c) => {
const path = c.req.query("path") || "/";
const result = await volumeService.browseFilesystem(path);
const response = {
directories: result.directories,
path: result.path,
};
return c.json<BrowseFilesystemDto>(response, 200);
});

View File

@@ -335,3 +335,39 @@ export const listFilesDto = describeRoute({
},
},
});
/**
* Browse filesystem directories
*/
export const browseFilesystemResponse = type({
directories: fileEntrySchema.array(),
path: "string",
});
export type BrowseFilesystemDto = typeof browseFilesystemResponse.infer;
export const browseFilesystemDto = describeRoute({
description: "Browse directories on the host filesystem",
operationId: "browseFilesystem",
tags: ["Volumes"],
parameters: [
{
in: "query",
name: "path",
required: false,
schema: {
type: "string",
},
description: "Directory path to browse (absolute path, defaults to /)",
},
],
responses: {
200: {
description: "List of directories in the specified path",
content: {
"application/json": {
schema: resolver(browseFilesystemResponse),
},
},
},
},
});

View File

@@ -276,7 +276,9 @@ const listFiles = async (name: string, subPath?: string) => {
throw new InternalServerError("Volume is not mounted");
}
const volumePath = getVolumePath(volume.name);
// For directory volumes, use the configured path directly
const volumePath =
volume.config.backend === "directory" ? volume.config.path : getVolumePath(volume.name);
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
@@ -328,6 +330,48 @@ const listFiles = async (name: string, subPath?: string) => {
}
};
const browseFilesystem = async (browsePath: string) => {
const normalizedPath = path.normalize(browsePath);
try {
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
const directories = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const fullPath = path.join(normalizedPath, entry.name);
try {
const stats = await fs.stat(fullPath);
return {
name: entry.name,
path: fullPath,
type: "directory" as const,
size: undefined,
modifiedAt: stats.mtimeMs,
};
} catch {
return {
name: entry.name,
path: fullPath,
type: "directory" as const,
size: undefined,
modifiedAt: undefined,
};
}
}),
);
return {
directories: directories.sort((a, b) => a.name.localeCompare(b.name)),
path: normalizedPath,
};
} catch (error) {
throw new InternalServerError(`Failed to browse filesystem: ${toMessage(error)}`);
}
};
export const volumeService = {
listVolumes,
createVolume,
@@ -340,4 +384,5 @@ export const volumeService = {
checkHealth,
getContainersUsingVolume,
listFiles,
browseFilesystem,
};