diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 7c62b8d..6fee3f9 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -19,6 +19,7 @@ import { unmountVolume, healthCheckVolume, listFiles, + browseFilesystem, listRepositories, createRepository, deleteRepository, @@ -67,6 +68,7 @@ import type { HealthCheckVolumeData, HealthCheckVolumeResponse, ListFilesData, + BrowseFilesystemData, ListRepositoriesData, CreateRepositoryData, CreateRepositoryResponse, @@ -647,6 +649,27 @@ export const listFilesOptions = (options: Options) => { }); }; +export const browseFilesystemQueryKey = (options?: Options) => + createQueryKey("browseFilesystem", options); + +/** + * Browse directories on the host filesystem + */ +export const browseFilesystemOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await browseFilesystem({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: browseFilesystemQueryKey(options), + }); +}; + export const listRepositoriesQueryKey = (options?: Options) => createQueryKey("listRepositories", options); diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 2f1c9e2..ada9f0b 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -40,6 +40,8 @@ import type { HealthCheckVolumeErrors, ListFilesData, ListFilesResponses, + BrowseFilesystemData, + BrowseFilesystemResponses, ListRepositoriesData, ListRepositoriesResponses, CreateRepositoryData, @@ -308,6 +310,18 @@ export const listFiles = (options: Options }); }; +/** + * Browse directories on the host filesystem + */ +export const browseFilesystem = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get({ + url: "/api/v1/volumes/filesystem/browse", + ...options, + }); +}; + /** * List all repositories */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index bd472e7..6ea9372 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -663,6 +663,36 @@ export type ListFilesResponses = { export type ListFilesResponse = ListFilesResponses[keyof ListFilesResponses]; +export type BrowseFilesystemData = { + body?: never; + path?: never; + query?: { + /** + * Directory path to browse (absolute path, defaults to /) + */ + path?: string; + }; + url: "/api/v1/volumes/filesystem/browse"; +}; + +export type BrowseFilesystemResponses = { + /** + * List of directories in the specified path + */ + 200: { + directories: Array<{ + name: string; + path: string; + type: "directory" | "file"; + modifiedAt?: number; + size?: number; + }>; + path: string; + }; +}; + +export type BrowseFilesystemResponse = BrowseFilesystemResponses[keyof BrowseFilesystemResponses]; + export type ListRepositoriesData = { body?: never; path?: never; diff --git a/apps/client/app/components/create-volume-form.tsx b/apps/client/app/components/create-volume-form.tsx index e6227d3..961fdc5 100644 --- a/apps/client/app/components/create-volume-form.tsx +++ b/apps/client/app/components/create-volume-form.tsx @@ -8,6 +8,7 @@ import { useForm } from "react-hook-form"; import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen"; import { cn, slugify } from "~/lib/utils"; import { deepClean } from "~/utils/object"; +import { DirectoryBrowser } from "./directory-browser"; import { Button } from "./ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; import { Input } from "./ui/input"; @@ -30,7 +31,7 @@ type Props = { }; const defaultValuesForType = { - directory: { backend: "directory" as const }, + directory: { backend: "directory" as const, path: "/" }, nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const }, smb: { backend: "smb" as const, port: 445, vers: "3.0" as const }, webdav: { backend: "webdav" as const, port: 80, ssl: false }, @@ -52,8 +53,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for const watchedName = watch("name"); useEffect(() => { - form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] }); - }, [watchedBackend, watchedName, form.reset]); + if (mode === "create") { + form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] }); + } + }, [watchedBackend, watchedName, form.reset, mode]); const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null); @@ -133,6 +136,39 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for )} /> + {watchedBackend === "directory" && ( + { + const [showBrowser, setShowBrowser] = useState(!field.value || field.value === "/"); + + return ( + + Directory Path + + {!showBrowser && field.value ? ( +
+
+
Selected path:
+
{field.value}
+
+ +
+ ) : ( + field.onChange(path)} selectedPath={field.value} /> + )} +
+ Browse and select a directory on the host filesystem to track. + +
+ ); + }} + /> + )} + {watchedBackend === "nfs" && ( <> void; + selectedPath?: string; +}; + +export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => { + const queryClient = useQueryClient(); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [fetchedFolders, setFetchedFolders] = useState>(new Set(["/"])); + const [loadingFolders, setLoadingFolders] = useState>(new Set()); + const [allFiles, setAllFiles] = useState>(new Map()); + + const { data, isLoading } = useQuery({ + ...browseFilesystemOptions({ query: { path: "/" } }), + }); + + useMemo(() => { + if (data?.directories) { + setAllFiles((prev) => { + const next = new Map(prev); + for (const dir of data.directories) { + next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" }); + } + return next; + }); + } + }, [data]); + + const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]); + + const handleFolderExpand = useCallback( + async (folderPath: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + next.add(folderPath); + return next; + }); + + if (!fetchedFolders.has(folderPath)) { + setLoadingFolders((prev) => new Set(prev).add(folderPath)); + + try { + const result = await queryClient.fetchQuery( + browseFilesystemOptions({ + query: { path: folderPath }, + }), + ); + + if (result.directories) { + setAllFiles((prev) => { + const next = new Map(prev); + for (const dir of result.directories) { + next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" }); + } + return next; + }); + + setFetchedFolders((prev) => new Set(prev).add(folderPath)); + } + } catch (error) { + console.error("Failed to fetch folder contents:", error); + } finally { + setLoadingFolders((prev) => { + const next = new Set(prev); + next.delete(folderPath); + return next; + }); + } + } + }, + [fetchedFolders, queryClient], + ); + + const handleFolderHover = useCallback( + (folderPath: string) => { + if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { + queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } })); + } + }, + [fetchedFolders, loadingFolders, queryClient], + ); + + if (isLoading && fileArray.length === 0) { + return ( +
+ +
Loading directories...
+
+
+ ); + } + + if (fileArray.length === 0) { + return ( +
+ +
No subdirectories found
+
+
+ ); + } + + return ( +
+ + + + + {selectedPath && ( +
+
Selected path:
+
{selectedPath}
+
+ )} +
+ ); +}; diff --git a/apps/client/app/components/file-tree.tsx b/apps/client/app/components/file-tree.tsx index 8d70b1e..88c45c1 100644 --- a/apps/client/app/components/file-tree.tsx +++ b/apps/client/app/components/file-tree.tsx @@ -36,6 +36,9 @@ interface Props { selectedPaths?: Set; onSelectionChange?: (selectedPaths: Set) => void; foldersOnly?: boolean; + selectableFolders?: boolean; + onFolderSelect?: (folderPath: string) => void; + selectedFolder?: string; } export const FileTree = memo((props: Props) => { @@ -52,6 +55,9 @@ export const FileTree = memo((props: Props) => { selectedPaths = new Set(), onSelectionChange, foldersOnly = false, + selectableFolders = false, + onFolderSelect, + selectedFolder, } = props; const fileList = useMemo(() => { @@ -126,6 +132,13 @@ export const FileTree = memo((props: Props) => { [onFileSelect], ); + const handleFolderSelect = useCallback( + (folderPath: string) => { + onFolderSelect?.(folderPath); + }, + [onFolderSelect], + ); + const handleSelectionChange = useCallback( (path: string, checked: boolean) => { const newSelection = new Set(selectedPaths); @@ -299,6 +312,9 @@ export const FileTree = memo((props: Props) => { checked={isPathSelected(fileOrFolder.fullPath) && !isPartiallySelected(fileOrFolder.fullPath)} partiallyChecked={isPartiallySelected(fileOrFolder.fullPath)} onCheckboxChange={handleSelectionChange} + selectableMode={selectableFolders} + onFolderSelect={handleFolderSelect} + selected={selectedFolder === fileOrFolder.fullPath} /> ); } @@ -321,6 +337,9 @@ interface FolderProps { checked?: boolean; partiallyChecked?: boolean; onCheckboxChange?: (path: string, checked: boolean) => void; + selectableMode?: boolean; + onFolderSelect?: (folderPath: string) => void; + selected?: boolean; } const Folder = memo( @@ -334,6 +353,9 @@ const Folder = memo( checked, partiallyChecked, onCheckboxChange, + selectableMode, + onFolderSelect, + selected, }: FolderProps) => { const { depth, name, fullPath } = folder; const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; @@ -359,9 +381,19 @@ const Folder = memo( [onCheckboxChange, fullPath], ); + const handleFolderClick = useCallback(() => { + if (selectableMode) { + onFolderSelect?.(fullPath); + } + }, [selectableMode, onFolderSelect, fullPath]); + return ( ) } + onClick={selectableMode ? handleFolderClick : undefined} onMouseEnter={handleMouseEnter} > {withCheckbox && ( diff --git a/apps/server/src/modules/backends/directory/directory-backend.ts b/apps/server/src/modules/backends/directory/directory-backend.ts index 4c119f8..d18cd2e 100644 --- a/apps/server/src/modules/backends/directory/directory-backend.ts +++ b/apps/server/src/modules/backends/directory/directory-backend.ts @@ -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), }); diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts index 6c0354d..3d72094 100644 --- a/apps/server/src/modules/backends/nfs/nfs-backend.ts +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -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), }); diff --git a/apps/server/src/modules/backends/smb/smb-backend.ts b/apps/server/src/modules/backends/smb/smb-backend.ts index 17870cd..be0e48f 100644 --- a/apps/server/src/modules/backends/smb/smb-backend.ts +++ b/apps/server/src/modules/backends/smb/smb-backend.ts @@ -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), }); diff --git a/apps/server/src/modules/backends/webdav/webdav-backend.ts b/apps/server/src/modules/backends/webdav/webdav-backend.ts index 5fa41ae..04175d9 100644 --- a/apps/server/src/modules/backends/webdav/webdav-backend.ts +++ b/apps/server/src/modules/backends/webdav/webdav-backend.ts @@ -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), }); diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index 319c1ca..18d4f98 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -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(), }); diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index 28482b7..b1a0177 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -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(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(response, 200); }); diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index 6c9f2c6..2fd06ba 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -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), + }, + }, + }, + }, +}); diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index cfdf583..1db9add 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -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, }; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index edc778e..dedc75f 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -32,6 +32,8 @@ export const smbConfigSchema = type({ export const directoryConfigSchema = type({ backend: "'directory'", + path: "string", + readOnly: "false?", }); export const webdavConfigSchema = type({ @@ -41,6 +43,7 @@ export const webdavConfigSchema = type({ username: "string?", password: "string?", port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80), + readOnly: "boolean?", ssl: "boolean?", });