mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: file explorer (#1)
* feat: list volume files backend * feat: file tree component * feat: load sub folders * fix: filetree wrong opening order * temp: open / close icons * chore: remove all hc files when cleaning * chore: file-tree optimizations
This commit is contained in:
@@ -4,6 +4,7 @@ import { execFile as execFileCb } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
@@ -33,5 +34,18 @@ export const createTestFile = async (path: string): Promise<void> => {
|
||||
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
|
||||
await fs.writeFile(testFilePath, "healthcheck");
|
||||
await fs.unlink(testFilePath);
|
||||
|
||||
const files = await fs.readdir(path);
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
if (file.startsWith(".healthcheck-")) {
|
||||
const filePath = npath.join(path, file);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to stat or unlink file ${filePath}: ${toMessage(err)}`);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
getVolumeDto,
|
||||
healthCheckDto,
|
||||
type ListContainersResponseDto,
|
||||
type ListFilesResponseDto,
|
||||
type ListVolumesResponseDto,
|
||||
listFilesDto,
|
||||
listVolumesDto,
|
||||
mountVolumeDto,
|
||||
testConnectionBody,
|
||||
@@ -118,4 +120,16 @@ export const volumeController = new Hono()
|
||||
const { error, status } = await volumeService.checkHealth(name);
|
||||
|
||||
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,50 @@ 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"],
|
||||
parameters: [
|
||||
{
|
||||
in: "query",
|
||||
name: "path",
|
||||
required: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
},
|
||||
description: "Subdirectory path to list (relative to volume root)",
|
||||
},
|
||||
],
|
||||
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 };
|
||||
};
|
||||
|
||||
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 = {
|
||||
listVolumes,
|
||||
createVolume,
|
||||
@@ -264,4 +327,5 @@ export const volumeService = {
|
||||
unmountVolume,
|
||||
checkHealth,
|
||||
getContainersUsingVolume,
|
||||
listFiles,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user