Compare commits

..

3 Commits

Author SHA1 Message Date
Nicolas Meienberger
d16be6cbca ci: create releases 2025-10-06 19:49:44 +02:00
Nico
1e3419c250 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
2025-10-06 19:46:49 +02:00
Nicolas Meienberger
a5e0fb6aa2 docs: update README 2025-10-04 14:50:39 +02:00
15 changed files with 728 additions and 7 deletions

View File

@@ -74,3 +74,23 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
publish-release:
runs-on: ubuntu-latest
needs: [build-images]
outputs:
id: ${{ steps.create_release.outputs.id }}
steps:
- name: Create GitHub release
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
body: |
**${{ needs.determine-release-type.outputs.tagname }}**
tag_name: ${{ needs.determine-release-type.outputs.tagname }}
name: ${{ needs.determine-release-type.outputs.tagname }}
draft: false
prerelease: true
files: cli/runtipi-cli-*

View File

@@ -41,7 +41,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml ```yaml
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.0.1 image: ghcr.io/nicotsx/ironmount:v0.1.1
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -72,7 +72,3 @@ Once the container is running, you can access the web interface at `http://<your
## Docker volume usage ## Docker volume usage
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/docker-instructions.png?raw=true) ![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/docker-instructions.png?raw=true)
## Volume creation
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?raw=true)

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,45 @@ export type HealthCheckVolumeResponses = {
export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses]; export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses];
export type ListFilesData = {
body?: never;
path: {
name: string;
};
query?: {
/**
* Subdirectory path to list (relative to volume root)
*/
path?: string;
};
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

@@ -0,0 +1,341 @@
/**
* FileTree Component
*
* Adapted from bolt.new by StackBlitz
* Copyright (c) 2024 StackBlitz, Inc.
* Licensed under the MIT License
*
* Original source: https://github.com/stackblitz/bolt.new
*/
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react";
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "~/lib/utils";
const NODE_PADDING_LEFT = 12;
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
interface Props {
files?: FileEntry[];
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
onFolderExpand?: (folderPath: string) => void;
expandedFolders?: Set<string>;
loadingFolders?: Set<string>;
className?: string;
}
export const FileTree = memo((props: Props) => {
const {
files = [],
onFileSelect,
selectedFile,
onFolderExpand,
expandedFolders = new Set(),
loadingFolders = new Set(),
className,
} = props;
const fileList = useMemo(() => {
return buildFileList(files);
}, [files]);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
const filteredFileList = useMemo(() => {
const list = [];
let lastDepth = Number.MAX_SAFE_INTEGER;
for (const fileOrFolder of fileList) {
const depth = fileOrFolder.depth;
// if the depth is equal we reached the end of the collapsed group
if (lastDepth === depth) {
lastDepth = Number.MAX_SAFE_INTEGER;
}
// ignore collapsed folders
if (collapsedFolders.has(fileOrFolder.fullPath)) {
lastDepth = Math.min(lastDepth, depth);
}
// ignore files and folders below the last collapsed folder
if (lastDepth < depth) {
continue;
}
list.push(fileOrFolder);
}
return list;
}, [fileList, collapsedFolders]);
const toggleCollapseState = useCallback(
(fullPath: string) => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(fullPath)) {
newSet.delete(fullPath);
onFolderExpand?.(fullPath);
} else {
newSet.add(fullPath);
}
return newSet;
});
},
[onFolderExpand],
);
// Add new folders to collapsed set when file list changes
useEffect(() => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
for (const item of fileList) {
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
newSet.add(item.fullPath);
}
}
return newSet;
});
}, [fileList, expandedFolders]);
const handleFileSelect = useCallback(
(filePath: string) => {
onFileSelect?.(filePath);
},
[onFileSelect],
);
return (
<div className={cn("text-sm", className)}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case "file": {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
onFileSelect={handleFileSelect}
/>
);
}
case "folder": {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
loading={loadingFolders.has(fileOrFolder.fullPath)}
onToggle={toggleCollapseState}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
});
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
loading?: boolean;
onToggle: (fullPath: string) => void;
}
const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
const handleClick = useCallback(() => {
onToggle(fullPath);
}, [onToggle, fullPath]);
return (
<NodeButton
className={cn("group hover:bg-accent/50 text-foreground")}
depth={depth}
icon={
loading ? (
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
) : collapsed ? (
<ChevronRight className="w-4 h-4 shrink-0" />
) : (
<ChevronDown className="w-4 h-4 shrink-0" />
)
}
onClick={handleClick}
>
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
<span className="truncate">{name}</span>
</NodeButton>
);
});
interface FileProps {
file: FileNode;
selected: boolean;
onFileSelect: (filePath: string) => void;
}
const File = memo(({ file, onFileSelect, selected }: FileProps) => {
const { depth, name, fullPath } = file;
const handleClick = useCallback(() => {
onFileSelect(fullPath);
}, [onFileSelect, fullPath]);
return (
<NodeButton
className={cn("group", {
"hover:bg-accent/50 text-foreground": !selected,
"bg-accent text-accent-foreground": selected,
})}
depth={depth}
icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />}
onClick={handleClick}
>
<span className="truncate">{name}</span>
</NodeButton>
);
});
interface ButtonProps {
depth: number;
icon: ReactNode;
children: ReactNode;
className?: string;
onClick?: () => void;
}
const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return (
<button
type="button"
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }}
onClick={onClick}
>
{icon}
<div className="truncate w-full flex items-center gap-2">{children}</div>
</button>
);
});
type Node = FileNode | FolderNode;
interface BaseNode {
id: number;
depth: number;
name: string;
fullPath: string;
}
interface FileNode extends BaseNode {
kind: "file";
}
interface FolderNode extends BaseNode {
kind: "folder";
}
function buildFileList(files: FileEntry[]): Node[] {
const fileMap = new Map<string, Node>();
for (const file of files) {
const segments = file.path.split("/").filter((segment) => segment);
const depth = segments.length - 1;
const name = segments[segments.length - 1];
if (!fileMap.has(file.path)) {
fileMap.set(file.path, {
kind: file.type === "file" ? "file" : "folder",
id: fileMap.size,
name,
fullPath: file.path,
depth,
});
}
}
// Convert map to array and sort
return sortFileList(Array.from(fileMap.values()));
}
function sortFileList(nodeList: Node[]): Node[] {
const nodeMap = new Map<string, Node>();
const childrenMap = new Map<string, Node[]>();
// Pre-sort nodes by name and type
nodeList.sort((a, b) => compareNodes(a, b));
for (const node of nodeList) {
nodeMap.set(node.fullPath, node);
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/";
if (parentPath !== "/") {
if (!childrenMap.has(parentPath)) {
childrenMap.set(parentPath, []);
}
childrenMap.get(parentPath)?.push(node);
}
}
const sortedList: Node[] = [];
const depthFirstTraversal = (path: string): void => {
const node = nodeMap.get(path);
if (node) {
sortedList.push(node);
}
const children = childrenMap.get(path);
if (children) {
for (const child of children) {
if (child.kind === "folder") {
depthFirstTraversal(child.fullPath);
} else {
sortedList.push(child);
}
}
}
};
// Start with root level items
const rootItems = nodeList.filter((node) => {
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/";
return parentPath === "/";
});
for (const item of rootItems) {
depthFirstTraversal(item.fullPath);
}
return sortedList;
}
function compareNodes(a: Node, b: Node): number {
if (a.kind !== b.kind) {
return a.kind === "folder" ? -1 : 1;
}
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
}

View File

@@ -0,0 +1,146 @@
import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { listFiles } from "~/api-client/sdk.gen";
import { FileTree } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { parseError } from "~/lib/errors";
import type { Volume } from "~/lib/types";
type Props = {
volume: Volume;
};
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
export const FilesTabContent = ({ volume }: Props) => {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
// Fetch root level files
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volume.name } }),
enabled: volume.status === "mounted",
refetchInterval: 10000,
});
useMemo(() => {
if (data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of data.files) {
next.set(file.path, file);
}
return next;
});
}
}, [data]);
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 listFiles({
path: { name: volume.name },
query: { path: folderPath },
throwOnError: true,
});
if (result.data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.data.files) {
next.set(file.path, file);
}
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;
});
}
}
},
[volume.name, fetchedFolders],
);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
if (volume.status !== "mounted") {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">Volume must be mounted to browse files.</p>
<p className="text-sm text-muted-foreground mt-2">Mount the volume to explore its contents.</p>
</CardContent>
</Card>
);
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader>
<CardTitle>File Explorer</CardTitle>
<CardDescription>Browse the files and folders in this volume.</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col">
{isLoading && (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Failed to load files: {error.message}</p>
</div>
)}
{!isLoading && !error && (
<div className="overflow-auto flex-1 border rounded-md bg-card">
{fileArray.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">This volume is empty.</p>
<p className="text-sm text-muted-foreground mt-2">
Files and folders will appear here once you add them.
</p>
</div>
) : (
<FileTree
files={fileArray}
onFolderExpand={handleFolderExpand}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
className="p-2"
/>
)}
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,7 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { getVolume } from "~/api-client";
import { import {
deleteVolumeMutation, deleteVolumeMutation,
getVolumeOptions, getVolumeOptions,
@@ -16,7 +15,9 @@ import { parseError } from "~/lib/errors";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups"; import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups";
import { DockerTabContent } from "~/modules/details/tabs/docker"; import { DockerTabContent } from "~/modules/details/tabs/docker";
import { FilesTabContent } from "~/modules/details/tabs/files";
import { VolumeInfoTabContent } from "~/modules/details/tabs/info"; import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
import { getVolume } from "../api-client";
import type { Route } from "./+types/details"; import type { Route } from "./+types/details";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
@@ -133,12 +134,16 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
<Tabs defaultValue="info" className="mt-4"> <Tabs defaultValue="info" className="mt-4">
<TabsList className="mb-2"> <TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger> <TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger> <TabsTrigger value="backups">Backups</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="info"> <TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} /> <VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent> </TabsContent>
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
<TabsContent value="docker"> <TabsContent value="docker">
<DockerTabContent volume={volume} /> <DockerTabContent volume={volume} />
</TabsContent> </TabsContent>

View File

@@ -4,6 +4,7 @@ import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { toMessage } from "../../../utils/errors";
const execFile = promisify(execFileCb); 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)}`); const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
await fs.writeFile(testFilePath, "healthcheck"); 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)}`);
}
}
}),
);
}; };

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,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",
},
},
});

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,
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 407 KiB