mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fcc9ada74 | ||
|
|
8a9d5fc3c8 | ||
|
|
219dec1c9c | ||
|
|
3bda6e81ae | ||
|
|
269116c25e | ||
|
|
c8fc5a1273 | ||
|
|
ae592481af | ||
|
|
65a7f436fe | ||
|
|
8af0bac63b | ||
|
|
41756e087a | ||
|
|
71ca5d3309 | ||
|
|
e29908757f | ||
|
|
15f0dc637d | ||
|
|
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-*
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
ARG BUN_VERSION="1.2.23"
|
ARG BUN_VERSION="1.3.0"
|
||||||
|
|
||||||
FROM oven/bun:${BUN_VERSION}-alpine AS runner_base
|
FROM oven/bun:${BUN_VERSION}-alpine AS runner_base
|
||||||
|
|
||||||
RUN apk add --no-cache davfs2=1.6.1-r2
|
RUN apk add --no-cache davfs2 restic
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# DEVELOPMENT
|
# DEVELOPMENT
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -15,7 +15,8 @@
|
|||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br />
|
> [!WARNING]
|
||||||
|
> Ironmount is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
|
||||||
|
|
||||||
## Intro
|
## Intro
|
||||||
|
|
||||||
@@ -41,7 +42,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.3.0
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -53,7 +54,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared
|
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
|
||||||
- ironmount_data:/data
|
- ironmount_data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -72,7 +73,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 & {});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<ScrollArea className="h-[500px]">
|
<ScrollArea className="h-[500px] p-4">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create volume</DialogTitle>
|
<DialogTitle>Create volume</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { cn, slugify } from "~/lib/utils";
|
import { cn, slugify } from "~/lib/utils";
|
||||||
|
import { deepClean } from "~/utils/object";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
@@ -15,6 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|||||||
export const formSchema = type({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
}).and(volumeConfigSchema);
|
}).and(volumeConfigSchema);
|
||||||
|
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||||
|
|
||||||
export type FormValues = typeof formSchema.inferIn;
|
export type FormValues = typeof formSchema.inferIn;
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ const defaultValuesForType = {
|
|||||||
|
|
||||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: arktypeResolver(formSchema),
|
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: true,
|
keepDefaultValues: true,
|
||||||
@@ -311,7 +313,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server</FormLabel>
|
<FormLabel>Server</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="192.168.1.100" value={field.value ?? ""} onChange={field.onChange} />
|
<Input placeholder="192.168.1.100" value={field.value} onChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>SMB server IP address or hostname.</FormDescription>
|
<FormDescription>SMB server IP address or hostname.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -325,7 +327,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Share</FormLabel>
|
<FormLabel>Share</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="myshare" value={field.value ?? ""} onChange={field.onChange} />
|
<Input placeholder="myshare" value={field.value} onChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>SMB share name on the server.</FormDescription>
|
<FormDescription>SMB share name on the server.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -339,7 +341,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="admin" value={field.value ?? ""} onChange={field.onChange} />
|
<Input placeholder="admin" value={field.value} onChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Username for SMB authentication.</FormDescription>
|
<FormDescription>Username for SMB authentication.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -353,7 +355,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="password" placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
<Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Password for SMB authentication.</FormDescription>
|
<FormDescription>Password for SMB authentication.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -450,11 +452,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</div>
|
</div>
|
||||||
{testMessage && (
|
{testMessage && (
|
||||||
<div
|
<div
|
||||||
className={`text-xs p-2 rounded-md ${
|
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
|
||||||
testMessage.success
|
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
|
||||||
? "bg-green-50 text-green-700 border border-green-200"
|
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
|
||||||
: "bg-red-50 text-red-700 border border-red-200"
|
})}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{testMessage.message}
|
{testMessage.message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
354
apps/client/app/components/file-tree.tsx
Normal file
354
apps/client/app/components/file-tree.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
onFolderHover?: (folderPath: string) => void;
|
||||||
|
expandedFolders?: Set<string>;
|
||||||
|
loadingFolders?: Set<string>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileTree = memo((props: Props) => {
|
||||||
|
const {
|
||||||
|
files = [],
|
||||||
|
onFileSelect,
|
||||||
|
selectedFile,
|
||||||
|
onFolderExpand,
|
||||||
|
onFolderHover,
|
||||||
|
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}
|
||||||
|
onHover={onFolderHover}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FolderProps {
|
||||||
|
folder: FolderNode;
|
||||||
|
collapsed: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onToggle: (fullPath: string) => void;
|
||||||
|
onHover?: (fullPath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Folder = memo(({ folder, collapsed, loading, onToggle, onHover }: FolderProps) => {
|
||||||
|
const { depth, name, fullPath } = folder;
|
||||||
|
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onToggle(fullPath);
|
||||||
|
}, [onToggle, fullPath]);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (collapsed) {
|
||||||
|
onHover?.(fullPath);
|
||||||
|
}
|
||||||
|
}, [onHover, fullPath, collapsed]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
>
|
||||||
|
<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;
|
||||||
|
onMouseEnter?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, 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}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
>
|
||||||
|
{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" });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { LifeBuoy } from "lucide-react";
|
||||||
import { Outlet, useNavigate } from "react-router";
|
import { Outlet, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
@@ -40,9 +41,22 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
|
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="default" size="sm" className="relative overflow-hidden">
|
||||||
|
<a
|
||||||
|
href="https://github.com/nicotsx/ironmount/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LifeBuoy />
|
||||||
|
<span>Report an issue</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
162
apps/client/app/modules/details/tabs/files.tsx
Normal file
162
apps/client/app/modules/details/tabs/files.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useQuery, useQueryClient } 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 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 queryClient = useQueryClient();
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prefetch folder contents on hover
|
||||||
|
const handleFolderHover = useCallback(
|
||||||
|
(folderPath: string) => {
|
||||||
|
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||||
|
queryClient.prefetchQuery(
|
||||||
|
listFilesOptions({
|
||||||
|
path: { name: volume.name },
|
||||||
|
query: { path: folderPath },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[volume.name, fetchedFolders, loadingFolders, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
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}
|
||||||
|
onFolderHover={handleFolderHover}
|
||||||
|
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, useSearchParams } 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) {
|
||||||
@@ -37,6 +38,8 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
|||||||
export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const activeTab = searchParams.get("tab") || "info";
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...getVolumeOptions({ path: { name: name ?? "" } }),
|
...getVolumeOptions({ path: { name: name ?? "" } }),
|
||||||
@@ -130,15 +133,19 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs defaultValue="info" className="mt-4">
|
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} 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>
|
||||||
|
|||||||
14
apps/client/app/utils/object.ts
Normal file
14
apps/client/app/utils/object.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export function deepClean<T>(obj: T): T {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(deepClean).filter((v) => v !== undefined && v !== null) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj && typeof obj === "object") {
|
||||||
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
|
const cleaned = deepClean(value);
|
||||||
|
if (cleaned !== undefined) acc[key as keyof T] = cleaned;
|
||||||
|
return acc;
|
||||||
|
}, {} as T);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"arktype": "^2.1.22",
|
"arktype": "^2.1.23",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
15
apps/server/drizzle/0006_secret_micromacro.sql
Normal file
15
apps/server/drizzle/0006_secret_micromacro.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `repositories_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`backend` text NOT NULL,
|
||||||
|
`config` text NOT NULL,
|
||||||
|
`compression_mode` text DEFAULT 'auto',
|
||||||
|
`status` text DEFAULT 'unknown',
|
||||||
|
`last_checked` integer,
|
||||||
|
`last_error` text,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `volumes_table` DROP COLUMN `path`;
|
||||||
311
apps/server/drizzle/meta/0006_snapshot.json
Normal file
311
apps/server/drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "16f360b6-fb61-44f3-a7f7-2bae78ebf7ca",
|
||||||
|
"prevId": "75f0aac0-aa63-4577-bfb6-4638a008935f",
|
||||||
|
"tables": {
|
||||||
|
"repositories_table": {
|
||||||
|
"name": "repositories_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"name": "backend",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"compression_mode": {
|
||||||
|
"name": "compression_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'auto'"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'unknown'"
|
||||||
|
},
|
||||||
|
"last_checked": {
|
||||||
|
"name": "last_checked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"repositories_table_name_unique": {
|
||||||
|
"name": "repositories_table_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions_table": {
|
||||||
|
"name": "sessions_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_table_user_id_users_table_id_fk": {
|
||||||
|
"name": "sessions_table_user_id_users_table_id_fk",
|
||||||
|
"tableFrom": "sessions_table",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_table_username_unique": {
|
||||||
|
"name": "users_table_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"volumes_table": {
|
||||||
|
"name": "volumes_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'unmounted'"
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_health_check": {
|
||||||
|
"name": "last_health_check",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auto_remount": {
|
||||||
|
"name": "auto_remount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"volumes_table_name_unique": {
|
||||||
|
"name": "volumes_table_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,13 @@
|
|||||||
"when": 1759416698274,
|
"when": 1759416698274,
|
||||||
"tag": "0005_simple_alice",
|
"tag": "0005_simple_alice",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1760734377440,
|
||||||
|
"tag": "0006_secret_micromacro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@types/bun": "^1.2.20",
|
"@types/bun": "^1.3.0",
|
||||||
"@types/dockerode": "^3.3.44",
|
"@types/dockerode": "^3.3.44",
|
||||||
"drizzle-kit": "^0.31.5"
|
"drizzle-kit": "^0.31.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export const OPERATION_TIMEOUT = 5000;
|
export const OPERATION_TIMEOUT = 5000;
|
||||||
export const VOLUME_MOUNT_BASE = "/var/lib/docker/volumes/ironmount";
|
export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
|
||||||
export const DATABASE_URL = "/data/ironmount.db";
|
export const DATABASE_URL = "/data/ironmount.db";
|
||||||
|
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import type { BackendStatus, BackendType, volumeConfigSchema } from "@ironmount/schemas";
|
import type {
|
||||||
|
BackendStatus,
|
||||||
|
BackendType,
|
||||||
|
CompressionMode,
|
||||||
|
RepositoryBackend,
|
||||||
|
RepositoryStatus,
|
||||||
|
repositoryConfigSchema,
|
||||||
|
volumeConfigSchema,
|
||||||
|
} from "@ironmount/schemas";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const volumesTable = sqliteTable("volumes_table", {
|
export const volumesTable = sqliteTable("volumes_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull().unique(),
|
||||||
path: text().notNull(),
|
|
||||||
type: text().$type<BackendType>().notNull(),
|
type: text().$type<BackendType>().notNull(),
|
||||||
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
||||||
lastError: text("last_error"),
|
lastError: text("last_error"),
|
||||||
@@ -38,3 +45,18 @@ export const sessionsTable = sqliteTable("sessions_table", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Session = typeof sessionsTable.$inferSelect;
|
export type Session = typeof sessionsTable.$inferSelect;
|
||||||
|
|
||||||
|
export const repositoriesTable = sqliteTable("repositories_table", {
|
||||||
|
id: text().primaryKey(),
|
||||||
|
name: text().notNull().unique(),
|
||||||
|
backend: text().$type<RepositoryBackend>().notNull(),
|
||||||
|
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
|
||||||
|
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
|
||||||
|
status: text().$type<RepositoryStatus>().default("unknown"),
|
||||||
|
lastChecked: int("last_checked", { mode: "timestamp" }),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as npath from "node:path";
|
import * as npath from "node:path";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
||||||
import type { VolumeBackend } from "../backend";
|
|
||||||
import { logger } from "../../../utils/logger";
|
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
|
import { logger } from "../../../utils/logger";
|
||||||
|
import type { VolumeBackend } from "../backend";
|
||||||
|
|
||||||
const mount = async (_config: BackendConfig, path: string) => {
|
const mount = async (_config: BackendConfig, path: string) => {
|
||||||
logger.info("Mounting directory volume...");
|
logger.info("Mounting directory volume...", path);
|
||||||
await fs.mkdir(path, { recursive: true });
|
await fs.mkdir(path, { recursive: true });
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
||||||
import type { VolumeBackend } from "../backend";
|
|
||||||
import { logger } from "../../../utils/logger";
|
|
||||||
import { withTimeout } from "../../../utils/timeout";
|
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
|
import { withTimeout } from "../../../utils/timeout";
|
||||||
|
import type { VolumeBackend } from "../backend";
|
||||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||||
|
|
||||||
const mount = async (config: BackendConfig, path: string) => {
|
const mount = async (config: BackendConfig, path: string) => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as npath from "node:path";
|
|
||||||
import * as fs from "node:fs/promises";
|
|
||||||
import { execFile as execFileCb } from "node:child_process";
|
import { execFile as execFileCb } from "node:child_process";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as npath from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
|
import { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
|
|
||||||
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,9 +43,17 @@ export const driverController = new Hono()
|
|||||||
Err: "",
|
Err: "",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post("/VolumeDriver.Path", (c) => {
|
.post("/VolumeDriver.Path", async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
if (!body.Name) {
|
||||||
|
return c.json({ Err: "Volume name is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: `/mnt/something`,
|
Mountpoint: `${VOLUME_MOUNT_BASE}/${volume.name}/_data`,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post("/VolumeDriver.Get", async (c) => {
|
.post("/VolumeDriver.Get", async (c) => {
|
||||||
|
|||||||
42
apps/server/src/modules/lifecycle/cleanup.ts
Normal file
42
apps/server/src/modules/lifecycle/cleanup.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||||
|
import { toMessage } from "../../utils/errors";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import { readMountInfo } from "../../utils/mountinfo";
|
||||||
|
import { executeUnmount } from "../backends/utils/backend-utils";
|
||||||
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
|
import { volumeService } from "../volumes/volume.service";
|
||||||
|
|
||||||
|
export const cleanupDanglingMounts = async () => {
|
||||||
|
const allVolumes = await volumeService.listVolumes();
|
||||||
|
const allSystemMounts = await readMountInfo();
|
||||||
|
|
||||||
|
for (const mount of allSystemMounts) {
|
||||||
|
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
|
||||||
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === mount.mountPoint);
|
||||||
|
if (!matchingVolume) {
|
||||||
|
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
||||||
|
await executeUnmount(mount.mountPoint);
|
||||||
|
|
||||||
|
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
|
||||||
|
logger.warn(`Failed to remove dangling mount directory ${path.dirname(mount.mountPoint)}: ${toMessage(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
|
||||||
|
|
||||||
|
for (const dir of allIronmountDirs) {
|
||||||
|
const volumePath = getVolumePath(dir);
|
||||||
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === volumePath);
|
||||||
|
if (!matchingVolume) {
|
||||||
|
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
|
||||||
|
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
|
||||||
|
await fs.rmdir(fullPath, { recursive: true }).catch((err) => {
|
||||||
|
logger.warn(`Failed to remove dangling mount directory ${fullPath}: ${toMessage(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,9 +3,14 @@ import { getTasks, schedule } from "node-cron";
|
|||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { volumesTable } from "../../db/schema";
|
import { volumesTable } from "../../db/schema";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
|
import { restic } from "../../utils/restic";
|
||||||
import { volumeService } from "../volumes/volume.service";
|
import { volumeService } from "../volumes/volume.service";
|
||||||
|
import { cleanupDanglingMounts } from "./cleanup";
|
||||||
|
|
||||||
export const startup = async () => {
|
export const startup = async () => {
|
||||||
|
await restic.ensurePassfile();
|
||||||
|
cleanupDanglingMounts();
|
||||||
|
|
||||||
const volumes = await db.query.volumesTable.findMany({
|
const volumes = await db.query.volumesTable.findMany({
|
||||||
where: or(
|
where: or(
|
||||||
eq(volumesTable.status, "mounted"),
|
eq(volumesTable.status, "mounted"),
|
||||||
@@ -20,8 +25,13 @@ export const startup = async () => {
|
|||||||
const existingTasks = getTasks();
|
const existingTasks = getTasks();
|
||||||
existingTasks.forEach(async (task) => await task.destroy());
|
existingTasks.forEach(async (task) => await task.destroy());
|
||||||
|
|
||||||
|
schedule("0 * * * *", async () => {
|
||||||
|
logger.debug("Running hourly cleanup of dangling mounts...");
|
||||||
|
await cleanupDanglingMounts();
|
||||||
|
});
|
||||||
|
|
||||||
schedule("* * * * *", async () => {
|
schedule("* * * * *", async () => {
|
||||||
logger.info("Running health check for all volumes...");
|
logger.debug("Running health check for all volumes...");
|
||||||
|
|
||||||
const volumes = await db.query.volumesTable.findMany({
|
const volumes = await db.query.volumesTable.findMany({
|
||||||
where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.status, "error")),
|
where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.status, "error")),
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
apps/server/src/modules/volumes/helpers.ts
Normal file
5
apps/server/src/modules/volumes/helpers.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||||
|
|
||||||
|
export const getVolumePath = (name: string) => {
|
||||||
|
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { validator } from "hono-openapi";
|
import { validator } from "hono-openapi";
|
||||||
|
import { getVolumePath } from "./helpers";
|
||||||
import {
|
import {
|
||||||
createVolumeBody,
|
createVolumeBody,
|
||||||
createVolumeDto,
|
createVolumeDto,
|
||||||
@@ -9,7 +10,9 @@ import {
|
|||||||
getVolumeDto,
|
getVolumeDto,
|
||||||
healthCheckDto,
|
healthCheckDto,
|
||||||
type ListContainersResponseDto,
|
type ListContainersResponseDto,
|
||||||
|
type ListFilesResponseDto,
|
||||||
type ListVolumesResponseDto,
|
type ListVolumesResponseDto,
|
||||||
|
listFilesDto,
|
||||||
listVolumesDto,
|
listVolumesDto,
|
||||||
mountVolumeDto,
|
mountVolumeDto,
|
||||||
testConnectionBody,
|
testConnectionBody,
|
||||||
@@ -27,6 +30,7 @@ export const volumeController = new Hono()
|
|||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
volumes: volumes.map((volume) => ({
|
volumes: volumes.map((volume) => ({
|
||||||
|
path: getVolumePath(volume.name),
|
||||||
...volume,
|
...volume,
|
||||||
updatedAt: volume.updatedAt.getTime(),
|
updatedAt: volume.updatedAt.getTime(),
|
||||||
createdAt: volume.createdAt.getTime(),
|
createdAt: volume.createdAt.getTime(),
|
||||||
@@ -61,6 +65,7 @@ export const volumeController = new Hono()
|
|||||||
const response = {
|
const response = {
|
||||||
volume: {
|
volume: {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
|
path: getVolumePath(res.volume.name),
|
||||||
createdAt: res.volume.createdAt.getTime(),
|
createdAt: res.volume.createdAt.getTime(),
|
||||||
updatedAt: res.volume.updatedAt.getTime(),
|
updatedAt: res.volume.updatedAt.getTime(),
|
||||||
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
||||||
@@ -93,6 +98,7 @@ export const volumeController = new Hono()
|
|||||||
message: "Volume updated",
|
message: "Volume updated",
|
||||||
volume: {
|
volume: {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
|
path: getVolumePath(res.volume.name),
|
||||||
createdAt: res.volume.createdAt.getTime(),
|
createdAt: res.volume.createdAt.getTime(),
|
||||||
updatedAt: res.volume.updatedAt.getTime(),
|
updatedAt: res.volume.updatedAt.getTime(),
|
||||||
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
||||||
@@ -118,4 +124,18 @@ 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;
|
||||||
|
|
||||||
|
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
|
||||||
|
|
||||||
|
return c.json(response, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { volumeConfigSchemaNoUndefined } from "@ironmount/schemas";
|
import { volumeConfigSchema } from "@ironmount/schemas";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ const volumeSchema = type({
|
|||||||
createdAt: "number",
|
createdAt: "number",
|
||||||
updatedAt: "number",
|
updatedAt: "number",
|
||||||
lastHealthCheck: "number",
|
lastHealthCheck: "number",
|
||||||
config: volumeConfigSchemaNoUndefined,
|
config: volumeConfigSchema,
|
||||||
autoRemount: "boolean",
|
autoRemount: "boolean",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export const listVolumesDto = describeRoute({
|
|||||||
*/
|
*/
|
||||||
export const createVolumeBody = type({
|
export const createVolumeBody = type({
|
||||||
name: "string",
|
name: "string",
|
||||||
config: volumeConfigSchemaNoUndefined,
|
config: volumeConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createVolumeResponse = type({
|
export const createVolumeResponse = type({
|
||||||
@@ -135,7 +135,7 @@ export const getVolumeDto = describeRoute({
|
|||||||
*/
|
*/
|
||||||
export const updateVolumeBody = type({
|
export const updateVolumeBody = type({
|
||||||
autoRemount: "boolean?",
|
autoRemount: "boolean?",
|
||||||
config: volumeConfigSchemaNoUndefined.optional(),
|
config: volumeConfigSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
|
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
|
||||||
@@ -170,7 +170,7 @@ export type UpdateVolumeResponseDto = typeof updateVolumeResponse.infer;
|
|||||||
* Test connection
|
* Test connection
|
||||||
*/
|
*/
|
||||||
export const testConnectionBody = type({
|
export const testConnectionBody = type({
|
||||||
config: volumeConfigSchemaNoUndefined,
|
config: volumeConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const testConnectionResponse = type({
|
export const testConnectionResponse = type({
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { toMessage } from "../../utils/errors";
|
|||||||
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
||||||
import { createVolumeBackend } from "../backends/backend";
|
import { createVolumeBackend } from "../backends/backend";
|
||||||
import type { UpdateVolumeBody } from "./volume.dto";
|
import type { UpdateVolumeBody } from "./volume.dto";
|
||||||
|
import { getVolumePath } from "./helpers";
|
||||||
|
|
||||||
const listVolumes = async () => {
|
const listVolumes = async () => {
|
||||||
const volumes = await db.query.volumesTable.findMany({});
|
const volumes = await db.query.volumesTable.findMany({});
|
||||||
@@ -31,14 +32,11 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||||||
throw new ConflictError("Volume already exists");
|
throw new ConflictError("Volume already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumePathHost = path.join(VOLUME_MOUNT_BASE);
|
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(volumesTable)
|
.insert(volumesTable)
|
||||||
.values({
|
.values({
|
||||||
name: slug,
|
name: slug,
|
||||||
config: backendConfig,
|
config: backendConfig,
|
||||||
path: path.join(volumePathHost, slug, "_data"),
|
|
||||||
type: backendConfig.backend,
|
type: backendConfig.backend,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -120,7 +118,7 @@ const getVolume = async (name: string) => {
|
|||||||
|
|
||||||
let statfs: Partial<StatFs> = {};
|
let statfs: Partial<StatFs> = {};
|
||||||
if (volume.status === "mounted") {
|
if (volume.status === "mounted") {
|
||||||
statfs = (await getStatFs(`${VOLUME_MOUNT_BASE}/${name}/_data`).catch(() => {})) ?? {};
|
statfs = await getStatFs(`${VOLUME_MOUNT_BASE}/${name}/_data`).catch(() => ({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { volume, statfs };
|
return { volume, statfs };
|
||||||
@@ -253,6 +251,71 @@ 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 volumePath = getVolumePath(volume.name);
|
||||||
|
|
||||||
|
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
|
||||||
|
|
||||||
|
const normalizedPath = path.normalize(requestedPath);
|
||||||
|
if (!normalizedPath.startsWith(volumePath)) {
|
||||||
|
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(volumePath, 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { ConflictError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, NotFoundError } from "http-errors-enhanced";
|
||||||
|
import { sanitizeSensitiveData } from "./sanitize";
|
||||||
|
|
||||||
export const handleServiceError = (error: unknown) => {
|
export const handleServiceError = (error: unknown) => {
|
||||||
if (error instanceof ConflictError) {
|
if (error instanceof ConflictError) {
|
||||||
return { message: error.message, status: 409 as const };
|
return { message: sanitizeSensitiveData(error.message), status: 409 as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof NotFoundError) {
|
if (error instanceof NotFoundError) {
|
||||||
return { message: error.message, status: 404 as const };
|
return { message: sanitizeSensitiveData(error.message), status: 404 as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { message: toMessage(error), status: 500 as const };
|
return { message: sanitizeSensitiveData(toMessage(error)), status: 500 as const };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toMessage = (err: unknown): string => {
|
export const toMessage = (err: unknown): string => {
|
||||||
return err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return sanitizeSensitiveData(message);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createLogger, format, transports } from "winston";
|
import { createLogger, format, transports } from "winston";
|
||||||
|
import { sanitizeSensitiveData } from "./sanitize";
|
||||||
|
|
||||||
const { printf, combine, colorize } = format;
|
const { printf, combine, colorize } = format;
|
||||||
|
|
||||||
@@ -14,14 +15,14 @@ const winstonLogger = createLogger({
|
|||||||
const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {
|
const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {
|
||||||
const stringMessages = messages.flatMap((m) => {
|
const stringMessages = messages.flatMap((m) => {
|
||||||
if (m instanceof Error) {
|
if (m instanceof Error) {
|
||||||
return [m.message, m.stack];
|
return [sanitizeSensitiveData(m.message), m.stack ? sanitizeSensitiveData(m.stack) : undefined].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof m === "object") {
|
if (typeof m === "object") {
|
||||||
return JSON.stringify(m, null, 2);
|
return sanitizeSensitiveData(JSON.stringify(m, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
return m;
|
return sanitizeSensitiveData(String(m));
|
||||||
});
|
});
|
||||||
|
|
||||||
winstonLogger.log(level, stringMessages.join(" "));
|
winstonLogger.log(level, stringMessages.join(" "));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
type MountInfo = {
|
type MountInfo = {
|
||||||
mountPoint: string;
|
mountPoint: string;
|
||||||
@@ -21,7 +21,7 @@ function unescapeMount(s: string): string {
|
|||||||
return s.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
return s.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readMountInfo(): Promise<MountInfo[]> {
|
export async function readMountInfo(): Promise<MountInfo[]> {
|
||||||
const text = await fs.readFile("/proc/self/mountinfo", "utf-8");
|
const text = await fs.readFile("/proc/self/mountinfo", "utf-8");
|
||||||
const result: MountInfo[] = [];
|
const result: MountInfo[] = [];
|
||||||
|
|
||||||
@@ -59,11 +59,27 @@ export async function getMountForPath(p: string): Promise<MountInfo | undefined>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getStatFs(mountPoint: string) {
|
export async function getStatFs(mountPoint: string) {
|
||||||
const stats = await fs.statfs(mountPoint);
|
const s = await fs.statfs(mountPoint, { bigint: true });
|
||||||
|
|
||||||
const total = Number(stats.blocks) * Number(stats.bsize);
|
const unit = s.bsize > 0n ? s.bsize : 1n;
|
||||||
const free = Number(stats.bfree) * Number(stats.bsize);
|
|
||||||
const used = total - free;
|
|
||||||
|
|
||||||
return { total, used, free };
|
const blocks = s.blocks > 0n ? s.blocks : 0n;
|
||||||
|
|
||||||
|
let bfree = s.bfree > 0n ? s.bfree : 0n;
|
||||||
|
if (bfree > blocks) bfree = blocks;
|
||||||
|
|
||||||
|
const bavail = s.bavail > 0n ? s.bavail : 0n;
|
||||||
|
|
||||||
|
const totalB = blocks * unit;
|
||||||
|
const usedB = (blocks - bfree) * unit;
|
||||||
|
const freeB = bavail * unit;
|
||||||
|
|
||||||
|
const MAX = BigInt(Number.MAX_SAFE_INTEGER);
|
||||||
|
const toNumber = (x: bigint) => (x > MAX ? Number.MAX_SAFE_INTEGER : Number(x));
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: toNumber(totalB),
|
||||||
|
used: toNumber(usedB),
|
||||||
|
free: toNumber(freeB),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
65
apps/server/src/utils/restic.ts
Normal file
65
apps/server/src/utils/restic.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import { $ } from "bun";
|
||||||
|
import { RESTIC_PASS_FILE } from "../core/constants";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
const backupOutputSchema = type({
|
||||||
|
message_type: "'summary'",
|
||||||
|
files_new: "number",
|
||||||
|
files_changed: "number",
|
||||||
|
files_unmodified: "number",
|
||||||
|
dirs_new: "number",
|
||||||
|
dirs_changed: "number",
|
||||||
|
dirs_unmodified: "number",
|
||||||
|
data_blobs: "number",
|
||||||
|
tree_blobs: "number",
|
||||||
|
data_added: "number",
|
||||||
|
total_files_processed: "number",
|
||||||
|
total_bytes_processed: "number",
|
||||||
|
total_duration: "number",
|
||||||
|
snapshot_id: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensurePassfile = async () => {
|
||||||
|
await fs.mkdir(path.dirname(RESTIC_PASS_FILE), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(RESTIC_PASS_FILE);
|
||||||
|
} catch {
|
||||||
|
logger.info("Restic passfile not found, creating a new one...");
|
||||||
|
await fs.writeFile(RESTIC_PASS_FILE, crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async (name: string) => {
|
||||||
|
const res =
|
||||||
|
await $`restic init --repo /data/repositories/${name} --password-file /data/secrets/restic.pass --json`.nothrow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const backup = async (repo: string, source: string) => {
|
||||||
|
const res =
|
||||||
|
await $`restic --repo /data/repositories/${repo} backup ${source} --password-file /data/secrets/restic.pass --json`.nothrow();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||||
|
throw new Error(`Restic backup failed: ${res.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = backupOutputSchema(res.json());
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
logger.error(`Restic backup output validation failed: ${result}`);
|
||||||
|
throw new Error(`Restic backup output validation failed: ${result}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restic = {
|
||||||
|
ensurePassfile,
|
||||||
|
init,
|
||||||
|
backup,
|
||||||
|
};
|
||||||
18
apps/server/src/utils/sanitize.ts
Normal file
18
apps/server/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Sanitizes sensitive information from strings
|
||||||
|
* This removes passwords and credentials from logs and error messages
|
||||||
|
*/
|
||||||
|
export const sanitizeSensitiveData = (text: string): string => {
|
||||||
|
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
|
||||||
|
|
||||||
|
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
|
||||||
|
|
||||||
|
sanitized = sanitized.replace(/(\S+)\s+(\S+)\s+(\S+)/g, (match, url, user, _pass) => {
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
|
return `${url} ${user} ***`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
};
|
||||||
26
bun.lock
26
bun.lock
@@ -27,7 +27,7 @@
|
|||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"arktype": "^2.1.22",
|
"arktype": "^2.1.23",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@types/bun": "^1.2.20",
|
"@types/bun": "^1.3.0",
|
||||||
"@types/dockerode": "^3.3.44",
|
"@types/dockerode": "^3.3.44",
|
||||||
"drizzle-kit": "^0.31.5",
|
"drizzle-kit": "^0.31.5",
|
||||||
},
|
},
|
||||||
@@ -96,9 +96,11 @@
|
|||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
],
|
],
|
||||||
"packages": {
|
"packages": {
|
||||||
"@ark/schema": ["@ark/schema@0.49.0", "", { "dependencies": { "@ark/util": "0.49.0" } }, "sha512-GphZBLpW72iS0v4YkeUtV3YIno35Gimd7+ezbPO9GwEi9kzdUrPVjvf6aXSBAfHikaFc/9pqZOpv3pOXnC71tw=="],
|
"@ark/regex": ["@ark/regex@0.0.0", "", { "dependencies": { "@ark/util": "0.50.0" } }, "sha512-p4vsWnd/LRGOdGQglbwOguIVhPmCAf5UzquvnDoxqhhPWTP84wWgi1INea8MgJ4SnI2gp37f13oA4Waz9vwNYg=="],
|
||||||
|
|
||||||
"@ark/util": ["@ark/util@0.49.0", "", {}, "sha512-/BtnX7oCjNkxi2vi6y1399b+9xd1jnCrDYhZ61f0a+3X8x8DxlK52VgEEzyuC2UQMPACIfYrmHkhD3lGt2GaMA=="],
|
"@ark/schema": ["@ark/schema@0.50.0", "", { "dependencies": { "@ark/util": "0.50.0" } }, "sha512-hfmP82GltBZDadIOeR3argKNlYYyB2wyzHp0eeAqAOFBQguglMV/S7Ip2q007bRtKxIMLDqFY6tfPie1dtssaQ=="],
|
||||||
|
|
||||||
|
"@ark/util": ["@ark/util@0.50.0", "", {}, "sha512-tIkgIMVRpkfXRQIEf0G2CJryZVtHVrqcWHMDa5QKo0OEEBu0tHkRSIMm4Ln8cd8Bn9TPZtvc/kE2Gma8RESPSg=="],
|
||||||
|
|
||||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||||
|
|
||||||
@@ -512,7 +514,7 @@
|
|||||||
|
|
||||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
|
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||||
|
|
||||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||||
|
|
||||||
@@ -570,7 +572,7 @@
|
|||||||
|
|
||||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"arktype": ["arktype@2.1.22", "", { "dependencies": { "@ark/schema": "0.49.0", "@ark/util": "0.49.0" } }, "sha512-xdzl6WcAhrdahvRRnXaNwsipCgHuNoLobRqhiP8RjnfL9Gp947abGlo68GAIyLtxbD+MLzNyH2YR4kEqioMmYQ=="],
|
"arktype": ["arktype@2.1.23", "", { "dependencies": { "@ark/regex": "0.0.0", "@ark/schema": "0.50.0", "@ark/util": "0.50.0" } }, "sha512-tyxNWX6xJVMb2EPJJ3OjgQS1G/vIeQRrZuY4DeBNQmh8n7geS+czgbauQWB6Pr+RXiOO8ChEey44XdmxsqGmfQ=="],
|
||||||
|
|
||||||
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
||||||
|
|
||||||
@@ -604,7 +606,7 @@
|
|||||||
|
|
||||||
"buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="],
|
"buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
|
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||||
|
|
||||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||||
|
|
||||||
@@ -1294,6 +1296,8 @@
|
|||||||
|
|
||||||
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
|
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
|
||||||
|
|
||||||
|
"@ironmount/server/arktype": ["arktype@2.1.22", "", { "dependencies": { "@ark/schema": "0.49.0", "@ark/util": "0.49.0" } }, "sha512-xdzl6WcAhrdahvRRnXaNwsipCgHuNoLobRqhiP8RjnfL9Gp947abGlo68GAIyLtxbD+MLzNyH2YR4kEqioMmYQ=="],
|
||||||
|
|
||||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
@@ -1332,8 +1336,6 @@
|
|||||||
|
|
||||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
|
|
||||||
|
|
||||||
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
|
|
||||||
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
@@ -1428,6 +1430,10 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"@ironmount/server/arktype/@ark/schema": ["@ark/schema@0.49.0", "", { "dependencies": { "@ark/util": "0.49.0" } }, "sha512-GphZBLpW72iS0v4YkeUtV3YIno35Gimd7+ezbPO9GwEi9kzdUrPVjvf6aXSBAfHikaFc/9pqZOpv3pOXnC71tw=="],
|
||||||
|
|
||||||
|
"@ironmount/server/arktype/@ark/util": ["@ark/util@0.49.0", "", {}, "sha512-/BtnX7oCjNkxi2vi6y1399b+9xd1jnCrDYhZ61f0a+3X8x8DxlK52VgEEzyuC2UQMPACIfYrmHkhD3lGt2GaMA=="],
|
||||||
|
|
||||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
@@ -1464,8 +1470,6 @@
|
|||||||
|
|
||||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
|
||||||
|
|
||||||
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared
|
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
|
||||||
- ironmount_data:/data
|
- ironmount_data:/data
|
||||||
|
|
||||||
- ./apps/client/app:/app/apps/client/app
|
- ./apps/client/app:/app/apps/client/app
|
||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared
|
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
|
||||||
- ironmount_data:/data
|
- ironmount_data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ironmount",
|
"name": "ironmount",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.2.23",
|
"packageManager": "bun@1.3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
"tsc": "turbo run tsc",
|
"tsc": "turbo run tsc",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const smbConfigSchema = type({
|
|||||||
username: "string",
|
username: "string",
|
||||||
password: "string",
|
password: "string",
|
||||||
vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"),
|
vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"),
|
||||||
domain: "string | undefined?",
|
domain: "string?",
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,17 +36,12 @@ export const webdavConfigSchema = type({
|
|||||||
backend: "'webdav'",
|
backend: "'webdav'",
|
||||||
server: "string",
|
server: "string",
|
||||||
path: "string",
|
path: "string",
|
||||||
username: "string | undefined?",
|
username: "string?",
|
||||||
password: "string | undefined?",
|
password: "string?",
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
||||||
ssl: "boolean?",
|
ssl: "boolean?",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const volumeConfigSchemaNoUndefined = nfsConfigSchema
|
|
||||||
.or(smbConfigSchema.omit("domain").and(type({ domain: "string?" })))
|
|
||||||
.or(webdavConfigSchema.omit("username", "password").and(type({ username: "string?", password: "string?" })))
|
|
||||||
.or(directoryConfigSchema);
|
|
||||||
|
|
||||||
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
|
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
|
||||||
|
|
||||||
export type BackendConfig = typeof volumeConfigSchema.infer;
|
export type BackendConfig = typeof volumeConfigSchema.infer;
|
||||||
@@ -58,3 +53,61 @@ export const BACKEND_STATUS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BackendStatus = keyof typeof BACKEND_STATUS;
|
export type BackendStatus = keyof typeof BACKEND_STATUS;
|
||||||
|
|
||||||
|
export const REPOSITORY_BACKENDS = {
|
||||||
|
local: "local",
|
||||||
|
sftp: "sftp",
|
||||||
|
s3: "s3",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||||
|
|
||||||
|
export const localRepositoryConfigSchema = type({
|
||||||
|
backend: "'local'",
|
||||||
|
path: "string",
|
||||||
|
password: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sftpRepositoryConfigSchema = type({
|
||||||
|
backend: "'sftp'",
|
||||||
|
host: "string",
|
||||||
|
user: "string",
|
||||||
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||||
|
path: "string",
|
||||||
|
sftpPassword: "string?",
|
||||||
|
sftpPrivateKey: "string?",
|
||||||
|
sftpCommand: "string?",
|
||||||
|
sftpArgs: "string?",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const s3RepositoryConfigSchema = type({
|
||||||
|
backend: "'s3'",
|
||||||
|
endpoint: "string",
|
||||||
|
bucket: "string",
|
||||||
|
accessKeyId: "string",
|
||||||
|
secretAccessKey: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const repositoryConfigSchema = localRepositoryConfigSchema
|
||||||
|
.or(sftpRepositoryConfigSchema)
|
||||||
|
.or(s3RepositoryConfigSchema);
|
||||||
|
|
||||||
|
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||||
|
|
||||||
|
export const COMPRESSION_MODES = {
|
||||||
|
off: "off",
|
||||||
|
auto: "auto",
|
||||||
|
fastest: "fastest",
|
||||||
|
better: "better",
|
||||||
|
max: "max",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CompressionMode = keyof typeof COMPRESSION_MODES;
|
||||||
|
|
||||||
|
export const REPOSITORY_STATUS = {
|
||||||
|
healthy: "healthy",
|
||||||
|
error: "error",
|
||||||
|
unknown: "unknown",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
||||||
|
|||||||
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