feat: list volume files backend
This commit is contained in:
parent
a5e0fb6aa2
commit
6e7846c165
6 changed files with 183 additions and 0 deletions
|
|
@ -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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 & {});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue