mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16be6cbca | ||
|
|
1e3419c250 | ||
|
|
a5e0fb6aa2 |
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@@ -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-*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Volume creation
|
|
||||||
|
|
||||||

|
|
||||||
|
|||||||
@@ -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,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 & {});
|
||||||
};
|
};
|
||||||
|
|||||||
341
apps/client/app/components/file-tree.tsx
Normal file
341
apps/client/app/components/file-tree.tsx
Normal 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" });
|
||||||
|
}
|
||||||
146
apps/client/app/modules/details/tabs/files.tsx
Normal file
146
apps/client/app/modules/details/tabs/files.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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 };
|
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 |
Reference in New Issue
Block a user