feat: list volume files backend

This commit is contained in:
Nicolas Meienberger 2025-10-04 17:35:28 +02:00
parent a5e0fb6aa2
commit 6e7846c165
6 changed files with 183 additions and 0 deletions

View file

@ -17,6 +17,7 @@ import {
mountVolume, mountVolume,
unmountVolume, unmountVolume,
healthCheckVolume, healthCheckVolume,
listFiles,
} from "../sdk.gen"; } from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
import type { import type {
@ -45,6 +46,7 @@ import type {
UnmountVolumeResponse, UnmountVolumeResponse,
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeResponse, HealthCheckVolumeResponse,
ListFilesData,
} from "../types.gen"; } from "../types.gen";
import { client as _heyApiClient } from "../client.gen"; import { client as _heyApiClient } from "../client.gen";
@ -539,3 +541,23 @@ export const healthCheckVolumeMutation = (
}; };
return mutationOptions; return mutationOptions;
}; };
export const listFilesQueryKey = (options: Options<ListFilesData>) => createQueryKey("listFiles", options);
/**
* List files in a volume directory
*/
export const listFilesOptions = (options: Options<ListFilesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listFiles({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listFilesQueryKey(options),
});
};

View file

@ -41,6 +41,9 @@ import type {
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeResponses, HealthCheckVolumeResponses,
HealthCheckVolumeErrors, HealthCheckVolumeErrors,
ListFilesData,
ListFilesResponses,
ListFilesErrors,
} from "./types.gen"; } from "./types.gen";
import { client as _heyApiClient } from "./client.gen"; import { client as _heyApiClient } from "./client.gen";
@ -248,3 +251,13 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
...options, ...options,
}); });
}; };
/**
* List files in a volume directory
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<ListFilesResponses, ListFilesErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/files",
...options,
});
};

View file

@ -600,6 +600,40 @@ export type HealthCheckVolumeResponses = {
export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses]; export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses];
export type ListFilesData = {
body?: never;
path: {
name: string;
};
query?: never;
url: "/api/v1/volumes/{name}/files";
};
export type ListFilesErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type ListFilesResponses = {
/**
* List of files in the volume
*/
200: {
files: Array<{
name: string;
path: string;
type: "directory" | "file";
modifiedAt?: number;
size?: number;
}>;
path: string;
};
};
export type ListFilesResponse = ListFilesResponses[keyof ListFilesResponses];
export type ClientOptions = { export type ClientOptions = {
baseUrl: "http://localhost:4096" | (string & {}); baseUrl: "http://localhost:4096" | (string & {});
}; };

View file

@ -9,7 +9,9 @@ import {
getVolumeDto, getVolumeDto,
healthCheckDto, healthCheckDto,
type ListContainersResponseDto, type ListContainersResponseDto,
type ListFilesResponseDto,
type ListVolumesResponseDto, type ListVolumesResponseDto,
listFilesDto,
listVolumesDto, listVolumesDto,
mountVolumeDto, mountVolumeDto,
testConnectionBody, testConnectionBody,
@ -118,4 +120,16 @@ export const volumeController = new Hono()
const { error, status } = await volumeService.checkHealth(name); const { error, status } = await volumeService.checkHealth(name);
return c.json({ error, status }, 200); return c.json({ error, status }, 200);
})
.get("/:name/files", listFilesDto, async (c) => {
const { name } = c.req.param();
const subPath = c.req.query("path");
const result = await volumeService.listFiles(name, subPath);
const response = {
files: result.files,
path: result.path,
} satisfies ListFilesResponseDto;
return c.json(response, 200);
}); });

View file

@ -305,3 +305,39 @@ export const getContainersDto = describeRoute({
}, },
}, },
}); });
/**
* List files in a volume
*/
const fileEntrySchema = type({
name: "string",
path: "string",
type: type.enumerated("file", "directory"),
size: "number?",
modifiedAt: "number?",
});
export const listFilesResponse = type({
files: fileEntrySchema.array(),
path: "string",
});
export type ListFilesResponseDto = typeof listFilesResponse.infer;
export const listFilesDto = describeRoute({
description: "List files in a volume directory",
operationId: "listFiles",
tags: ["Volumes"],
responses: {
200: {
description: "List of files in the volume",
content: {
"application/json": {
schema: resolver(listFilesResponse),
},
},
},
404: {
description: "Volume not found",
},
},
});

View file

@ -253,6 +253,69 @@ const getContainersUsingVolume = async (name: string) => {
return { containers: usingContainers }; return { containers: usingContainers };
}; };
const listFiles = async (name: string, subPath?: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
if (volume.status !== "mounted") {
throw new InternalServerError("Volume is not mounted");
}
const requestedPath = subPath ? path.join(volume.path, subPath) : volume.path;
const normalizedPath = path.normalize(requestedPath);
if (!normalizedPath.startsWith(volume.path)) {
throw new InternalServerError("Invalid path");
}
try {
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(normalizedPath, entry.name);
const relativePath = path.relative(volume.path, fullPath);
try {
const stats = await fs.stat(fullPath);
return {
name: entry.name,
path: `/${relativePath}`,
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
size: entry.isFile() ? stats.size : undefined,
modifiedAt: stats.mtimeMs,
};
} catch {
return {
name: entry.name,
path: `/${relativePath}`,
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
size: undefined,
modifiedAt: undefined,
};
}
}),
);
return {
files: files.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
}),
path: subPath || "/",
};
} catch (error) {
throw new InternalServerError(`Failed to list files: ${toMessage(error)}`);
}
};
export const volumeService = { export const volumeService = {
listVolumes, listVolumes,
createVolume, createVolume,
@ -264,4 +327,5 @@ export const volumeService = {
unmountVolume, unmountVolume,
checkHealth, checkHealth,
getContainersUsingVolume, getContainersUsingVolume,
listFiles,
}; };