Compare commits

..

17 Commits

Author SHA1 Message Date
Nicolas Meienberger
ae592481af feat: base restic repo schemas 2025-10-17 21:03:13 +02:00
Nicolas Meienberger
65a7f436fe fix: clean undefined values before posting form 2025-10-17 21:01:32 +02:00
Nicolas Meienberger
8af0bac63b feat: restic pass file generation 2025-10-17 13:15:24 +02:00
Nicolas Meienberger
41756e087a feat: report an issue button 2025-10-15 22:16:48 +02:00
Nicolas Meienberger
71ca5d3309 chore: bump bun to 1.3.0 2025-10-15 22:16:40 +02:00
Nico
e29908757f Revise warning message in README
Updated warning message to encourage feature requests.
2025-10-10 19:05:45 +02:00
Nicolas Meienberger
15f0dc637d chore: improve logging
Update README with version warning

Update Ironmount image version to v0.2.0
2025-10-09 22:41:56 +02:00
Nicolas Meienberger
d16be6cbca ci: create releases 2025-10-06 19:49:44 +02:00
Nico
1e3419c250 feat: file explorer (#1)
* feat: list volume files backend

* feat: file tree component

* feat: load sub folders

* fix: filetree wrong opening order

* temp: open / close icons

* chore: remove all hc files when cleaning

* chore: file-tree optimizations
2025-10-06 19:46:49 +02:00
Nicolas Meienberger
a5e0fb6aa2 docs: update README 2025-10-04 14:50:39 +02:00
Nicolas Meienberger
3d87814aee ui: empty state 2025-10-04 14:43:29 +02:00
Nicolas Meienberger
56a4afdc92 chore: remove prismjs 2025-10-04 14:20:36 +02:00
Nicolas Meienberger
728cfebeb7 refactor: extract grid background 2025-10-04 14:02:34 +02:00
Nicolas Meienberger
472f7799a4 ui: redesign tabs 2025-10-04 13:26:34 +02:00
Nicolas Meienberger
e134d0e1d1 ui: redesign 2025-10-04 00:16:47 +02:00
Nicolas Meienberger
689f14dff7 ui: card corner accent 2025-10-03 23:11:14 +02:00
Nicolas Meienberger
8d46074bb1 fix: issue with the test connection message 2025-10-03 21:50:57 +02:00
46 changed files with 1284 additions and 380 deletions

View File

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

View File

@@ -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

View File

@@ -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.2.0
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -72,7 +73,3 @@ Once the container is running, you can access the web interface at `http://<your
## Docker volume usage ## Docker volume usage
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/docker-instructions.png?raw=true) ![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/docker-instructions.png?raw=true)
## Volume creation
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?raw=true)

View File

@@ -17,6 +17,7 @@ import {
mountVolume, mountVolume,
unmountVolume, unmountVolume,
healthCheckVolume, healthCheckVolume,
listFiles,
} from "../sdk.gen"; } from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
import type { import type {
@@ -45,6 +46,7 @@ import type {
UnmountVolumeResponse, UnmountVolumeResponse,
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeResponse, HealthCheckVolumeResponse,
ListFilesData,
} from "../types.gen"; } from "../types.gen";
import { client as _heyApiClient } from "../client.gen"; import { client as _heyApiClient } from "../client.gen";
@@ -539,3 +541,23 @@ export const healthCheckVolumeMutation = (
}; };
return mutationOptions; return mutationOptions;
}; };
export const listFilesQueryKey = (options: Options<ListFilesData>) => createQueryKey("listFiles", options);
/**
* List files in a volume directory
*/
export const listFilesOptions = (options: Options<ListFilesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listFiles({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listFilesQueryKey(options),
});
};

View File

@@ -41,6 +41,9 @@ import type {
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeResponses, HealthCheckVolumeResponses,
HealthCheckVolumeErrors, HealthCheckVolumeErrors,
ListFilesData,
ListFilesResponses,
ListFilesErrors,
} from "./types.gen"; } from "./types.gen";
import { client as _heyApiClient } from "./client.gen"; import { client as _heyApiClient } from "./client.gen";
@@ -248,3 +251,13 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
...options, ...options,
}); });
}; };
/**
* List files in a volume directory
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<ListFilesResponses, ListFilesErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/files",
...options,
});
};

View File

@@ -600,6 +600,45 @@ export type HealthCheckVolumeResponses = {
export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses]; export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses];
export type ListFilesData = {
body?: never;
path: {
name: string;
};
query?: {
/**
* Subdirectory path to list (relative to volume root)
*/
path?: string;
};
url: "/api/v1/volumes/{name}/files";
};
export type ListFilesErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type ListFilesResponses = {
/**
* List of files in the volume
*/
200: {
files: Array<{
name: string;
path: string;
type: "directory" | "file";
modifiedAt?: number;
size?: number;
}>;
path: string;
};
};
export type ListFilesResponse = ListFilesResponses[keyof ListFilesResponses];
export type ClientOptions = { export type ClientOptions = {
baseUrl: "http://localhost:4096" | (string & {}); baseUrl: "http://localhost:4096" | (string & {});
}; };

View File

@@ -11,7 +11,7 @@
html, html,
body { body {
@apply bg-white dark:bg-[#0D0D0D]; @apply bg-white dark:bg-[#131313];
overflow-x: hidden; overflow-x: hidden;
width: 100%; width: 100%;
position: relative; position: relative;
@@ -30,6 +30,7 @@ body {
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card-header: var(--card-header);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
@@ -57,6 +58,7 @@ body {
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-strong-accent: var(--strong-accent);
} }
:root { :root {
@@ -65,6 +67,7 @@ body {
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--card-header: oklch(0.922 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
@@ -92,12 +95,14 @@ body {
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--strong-accent: #ff543a;
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: #131313;
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.1448 0 0); --card: #131313;
--card-header: #1b1b1b;
/* --card: oklch(0.205 0 0); ORIGINAL */ /* --card: oklch(0.205 0 0); ORIGINAL */
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
@@ -111,7 +116,7 @@ body {
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: #ff543a;
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
@@ -128,6 +133,7 @@ body {
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
--strong-accent: #ff543a;
} }
@layer base { @layer base {

View File

@@ -14,14 +14,20 @@ export function AppBreadcrumb() {
const breadcrumbs = useBreadcrumbs(); const breadcrumbs = useBreadcrumbs();
return ( return (
<Breadcrumb className={cn("mb-2", { invisible: breadcrumbs.length <= 1 })}> <Breadcrumb>
<BreadcrumbLink asChild></BreadcrumbLink>
<BreadcrumbList> <BreadcrumbList>
{breadcrumbs.length === 1 && (
<BreadcrumbItem>
<Link to="/">Ironmount</Link>
</BreadcrumbItem>
)}
{breadcrumbs.map((breadcrumb, index) => { {breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1; const isLast = index === breadcrumbs.length - 1;
return ( return (
<div key={`${breadcrumb.label}-${index}`} className="contents"> <div key={`${breadcrumb.label}-${index}`} className="contents">
<BreadcrumbItem> <BreadcrumbItem className={cn({ invisible: breadcrumbs.length <= 1 })}>
{isLast || breadcrumb.isCurrentPage ? ( {isLast || breadcrumb.isCurrentPage ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage> <BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
) : breadcrumb.href ? ( ) : breadcrumb.href ? (

View File

@@ -6,15 +6,7 @@ import { createVolumeMutation } from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors"; import { parseError } from "~/lib/errors";
import { CreateVolumeForm } from "./create-volume-form"; import { CreateVolumeForm } from "./create-volume-form";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
type Props = { type Props = {
@@ -41,13 +33,13 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="bg-blue-900 hover:bg-blue-800"> <Button>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Create volume Create volume
</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>

View File

@@ -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,
@@ -53,22 +55,21 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] }); form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
}, [watchedBackend, watchedName, form.reset]); }, [watchedBackend, watchedName, form.reset]);
const [testMessage, setTestMessage] = useState<string>(""); const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
const testBackendConnection = useMutation({ const testBackendConnection = useMutation({
...testConnectionMutation(), ...testConnectionMutation(),
onMutate: () => { onMutate: () => {
setTestMessage(""); setTestMessage(null);
}, },
onError: () => { onError: (error) => {
setTestMessage("Failed to test connection. Please try again."); setTestMessage({
success: false,
message: error?.message || "Failed to test connection. Please try again.",
});
}, },
onSuccess: (data) => { onSuccess: (data) => {
if (data?.success) { setTestMessage(data);
setTestMessage(data.message);
} else {
setTestMessage(data?.message || "Connection test failed");
}
}, },
}); });
@@ -312,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 />
@@ -326,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 />
@@ -340,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 />
@@ -354,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 />
@@ -424,112 +425,44 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</> </>
)} )}
{watchedBackend === "smb" && ( <div className="space-y-3">
<div className="space-y-3"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <Button
<Button type="button"
type="button" variant="outline"
variant="outline" onClick={handleTestConnection}
onClick={handleTestConnection} disabled={testBackendConnection.isPending}
disabled={testBackendConnection.isPending} className="flex-1"
className="flex-1" >
> {testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {!testBackendConnection.isPending && testMessage?.success && (
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />} <CheckCircle className="mr-2 h-4 w-4 text-green-500" />
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />} )}
{testBackendConnection.isIdle && "Test Connection"} {!testBackendConnection.isPending && testMessage && !testMessage.success && (
{testBackendConnection.isPending && "Testing..."} <XCircle className="mr-2 h-4 w-4 text-red-500" />
{testBackendConnection.isSuccess && "Connection Successful"} )}
{testBackendConnection.isError && "Test Failed"} {testBackendConnection.isPending
</Button> ? "Testing..."
</div> : testMessage
{testMessage && ( ? testMessage.success
<div ? "Connection Successful"
className={`text-sm p-2 rounded-md ${ : "Test Failed"
testBackendConnection.isSuccess : "Test Connection"}
? "bg-green-50 text-green-700 border border-green-200" </Button>
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
</div> </div>
)} {testMessage && (
<div
{watchedBackend === "nfs" && ( className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
<div className="space-y-3"> "bg-green-50 text-green-700 border border-green-200": testMessage.success,
<div className="flex items-center gap-2"> "bg-red-50 text-red-700 border border-red-200": !testMessage.success,
<Button })}
type="button" >
variant="outline" {testMessage.message}
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
</div> </div>
{testMessage && ( )}
<div </div>
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
</div>
)}
{watchedBackend === "webdav" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
</div>
)}
{mode === "update" && ( {mode === "update" && (
<Button type="submit" className="w-full mt-4" loading={loading}> <Button type="submit" className="w-full" loading={loading}>
Save Changes Save Changes
</Button> </Button>
)} )}

View File

@@ -0,0 +1,56 @@
import { Database, HardDrive, HeartPulse, Plus } from "lucide-react";
import { CreateVolumeDialog } from "./create-volume-dialog";
import { useState } from "react";
export function EmptyState() {
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3 mb-8">
<h3 className="text-2xl font-semibold text-foreground">No volumes yet</h3>
<p className="text-muted-foreground">
Get started by creating your first volume. Manage and monitor all your storage backends in one place with
advanced features like automatic mounting and health checks.
</p>
</div>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-0 max-w-3xl">
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Database className="w-5 h-5 text-primary" />
</div>
<h4 className="font-medium text-sm">Multiple Backends</h4>
<p className="text-xs text-muted-foreground">Support for local, NFS, and SMB storage</p>
</div>
<div className="flex flex-col items-center gap-2 p-4 border border-r-0 border-l-0 bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<HardDrive className="w-5 h-5 text-primary" />
</div>
<h4 className="font-medium text-sm">Auto Mounting</h4>
<p className="text-xs text-muted-foreground">Automatic lifecycle management</p>
</div>
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<HeartPulse className="w-5 h-5 text-primary" />
</div>
<h4 className="font-medium text-sm">Real-time Monitoring</h4>
<p className="text-xs text-muted-foreground">Live status and health checks</p>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
interface GridBackgroundProps {
children: ReactNode;
className?: string;
containerClassName?: string;
}
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
return (
<div
className={cn(
"relative min-h-dvh w-full overflow-x-hidden",
"[background-size:20px_20px] sm:[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
containerClassName,
)}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-card" />
<div className={cn("relative h-screen", className)}>{children}</div>
</div>
);
}

View File

@@ -1,13 +1,14 @@
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 { cn } from "~/lib/utils";
import { AppBreadcrumb } from "./app-breadcrumb";
import { Button } from "./ui/button";
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen"; import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
import type { Route } from "./+types/layout";
import { appContext } from "~/context"; import { appContext } from "~/context";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/layout";
import { AppBreadcrumb } from "./app-breadcrumb";
import { GridBackground } from "./grid-background";
import { Button } from "./ui/button";
export const clientMiddleware = [authMiddleware]; export const clientMiddleware = [authMiddleware];
@@ -31,29 +32,38 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
}); });
return ( return (
<div <GridBackground>
className={cn( <header className="bg-card-header border-b border-border/50">
"relative min-h-dvh w-full overflow-x-hidden", <div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-4 container mx-auto">
"[background-size:20px_20px] sm:[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div>
<main className="relative flex flex-col pt-4 sm:pt-8 px-2 sm:px-4 pb-4 container mx-auto">
<div className="flex items-center justify-between mb-4">
<AppBreadcrumb /> <AppBreadcrumb />
{loaderData.user && ( {loaderData.user && (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">Welcome, {loaderData.user?.username}</span> <span className="text-sm text-muted-foreground">
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
</span>
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}> <Button variant="outline" 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>
</header>
<main className="flex flex-col pt-4 sm:pt-8 px-2 sm:px-4 pb-4 container mx-auto">
<Outlet /> <Outlet />
</main> </main>
</div> </GridBackground>
); );
} }

View File

@@ -6,23 +6,23 @@ import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex cursor-pointer uppercase border items-center justify-center dark:border-white dark:bg-secondary dark:text-white gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
outline: outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-5 py-2 has-[>svg]:px-4",
sm: "rounded-xs h-8 gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 px-3 py-1.5 has-[>svg]:px-2.5",
lg: "h-10 px-6 has-[>svg]:px-4", lg: "h-10 px-6 py-2.5 has-[>svg]:px-5",
icon: "size-9", icon: "size-9",
}, },
}, },

View File

@@ -1,17 +1,26 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, children, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn("bg-card text-card-foreground relative flex flex-col gap-6 border-2 py-6 shadow-sm", className)}
"bg-card text-card-foreground flex flex-col gap-6 border-2 py-6 shadow-sm",
className,
)}
{...props} {...props}
/> >
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
<span className="absolute left-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
</span>
{children}
</div>
); );
} }
@@ -29,64 +38,31 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props} {...props}
/> />
); );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
); );
} }
export { export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -1,7 +1,4 @@
import React, { useEffect } from "react"; import type React from "react";
import Prism from "prismjs";
import "prismjs/themes/prism-twilight.css";
import "prismjs/components/prism-yaml";
import { toast } from "sonner"; import { toast } from "sonner";
import { copyToClipboard } from "~/utils/clipboard"; import { copyToClipboard } from "~/utils/clipboard";
@@ -11,35 +8,31 @@ interface CodeBlockProps {
filename?: string; filename?: string;
} }
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = "jsx", filename }) => { export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
useEffect(() => {
Prism.highlightAll();
}, []);
const handleCopy = async () => { const handleCopy = async () => {
await copyToClipboard(code); await copyToClipboard(code);
toast.success("Code copied to clipboard"); toast.success("Code copied to clipboard");
}; };
return ( return (
<div className="overflow-hidden rounded-sm bg-slate-900 ring-1 ring-white/10"> <div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs text-slate-400"> <div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" /> <span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" /> <span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" /> <span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
{filename && <span className="ml-3 font-medium text-slate-300">{filename}</span>} {filename && <span className="ml-3 font-medium">{filename}</span>}
</div> </div>
<button <button
type="button" type="button"
onClick={() => handleCopy()} onClick={() => handleCopy()}
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium text-slate-300 ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px" className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
> >
Copy Copy
</button> </button>
</div> </div>
<pre className="overflow-x-auto leading-6 text-xs m-0" style={{ marginTop: 0, marginBottom: 0 }}> <pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className={`language-${language}`}>{code}</code> <code className="text-white/80">{code}</code>
</pre> </pre>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className,

View File

@@ -1,64 +1,58 @@
import * as React from "react" import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as TabsPrimitive from "@radix-ui/react-tabs" import type * as React from "react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
function Tabs({ function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
className, return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
} }
function TabsList({ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
className, return (
...props <TabsPrimitive.List
}: React.ComponentProps<typeof TabsPrimitive.List>) { data-slot="tabs-list"
return ( className={cn("inline-flex h-7 items-center gap-4 text-xs text-muted-foreground", className)}
<TabsPrimitive.List {...props}
data-slot="tabs-list" />
className={cn( );
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
} }
function TabsTrigger({ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
className, return (
...props <TabsPrimitive.Trigger
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) { data-slot="tabs-trigger"
return ( className={cn(
<TabsPrimitive.Trigger "cursor-pointer group relative inline-flex h-7 items-center whitespace-nowrap text-xs font-medium transition-colors",
data-slot="tabs-trigger" "text-muted-foreground data-[state=active]:text-foreground disabled:pointer-events-none disabled:opacity-50",
className={cn( "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className // Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
)} "px-5",
{...props} // Transparent orange background for active state
/> "data-[state=active]:bg-[#FF453A]/10",
) // Left bracket - vertical line
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
// Left bracket - top tick
"after:absolute after:left-0 after:top-[-1px] after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
className,
)}
{...props}
>
<span className="relative z-10">{props.children}</span>
{/* Left bracket - bottom tick */}
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - top tick */}
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - vertical line */}
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - bottom tick */}
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
</TabsPrimitive.Trigger>
);
} }
function TabsContent({ function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
className, return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
} }
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -42,10 +42,10 @@ const getIconAndColor = (backend: BackendType) => {
}; };
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => { export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
const { icon: Icon, color, label } = getIconAndColor(backend); const { icon: Icon, label } = getIconAndColor(backend);
return ( return (
<span className={`flex items-center gap-2 ${color} rounded-md px-2 py-1`}> <span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
<Icon size={size} /> <Icon size={size} />
{label} {label}
</span> </span>

View File

@@ -18,7 +18,7 @@ export function StorageChart({ statfs }: Props) {
{ {
name: "Used", name: "Used",
value: statfs.used, value: statfs.used,
fill: "#2B7EFF", fill: "#ff543a",
}, },
{ {
name: "Free", name: "Free",
@@ -63,7 +63,7 @@ export function StorageChart({ statfs }: Props) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 pb-0"> <CardContent className="flex-1 pb-0">
<div className=""> <div>
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]"> <ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<PieChart> <PieChart>
<ChartTooltip <ChartTooltip
@@ -105,9 +105,9 @@ export function StorageChart({ statfs }: Props) {
<ByteSize bytes={statfs.total} className="font-mono text-sm" /> <ByteSize bytes={statfs.total} className="font-mono text-sm" />
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg bg-blue-500/10"> <div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-4 w-4 rounded-full bg-blue-500" /> <div className="h-4 w-4 rounded-full bg-strong-accent" />
<span className="font-medium">Used Space</span> <span className="font-medium">Used Space</span>
</div> </div>
<div className="text-right"> <div className="text-right">

View File

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

View File

@@ -1,7 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { getVolume } from "~/api-client";
import { import {
deleteVolumeMutation, deleteVolumeMutation,
getVolumeOptions, getVolumeOptions,
@@ -16,7 +15,9 @@ import { parseError } from "~/lib/errors";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups"; import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups";
import { DockerTabContent } from "~/modules/details/tabs/docker"; import { DockerTabContent } from "~/modules/details/tabs/docker";
import { FilesTabContent } from "~/modules/details/tabs/files";
import { VolumeInfoTabContent } from "~/modules/details/tabs/info"; import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
import { getVolume } from "../api-client";
import type { Route } from "./+types/details"; import type { Route } from "./+types/details";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
@@ -111,7 +112,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button
variant="secondary"
onClick={() => mountVol.mutate({ path: { name } })} onClick={() => mountVol.mutate({ path: { name } })}
loading={mountVol.isPending} loading={mountVol.isPending}
className={cn({ hidden: volume.status === "mounted" })} className={cn({ hidden: volume.status === "mounted" })}
@@ -131,15 +131,19 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
</Button> </Button>
</div> </div>
</div> </div>
<Tabs defaultValue="info" className="mt-0"> <Tabs defaultValue="info" className="mt-4">
<TabsList> <TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger> <TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger> <TabsTrigger value="backups">Backups</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="info"> <TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} /> <VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent> </TabsContent>
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
<TabsContent value="docker"> <TabsContent value="docker">
<DockerTabContent volume={volume} /> <DockerTabContent volume={volume} />
</TabsContent> </TabsContent>

View File

@@ -5,11 +5,13 @@ import { useNavigate } from "react-router";
import { listVolumes } from "~/api-client"; import { listVolumes } from "~/api-client";
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen"; import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
import { CreateVolumeDialog } from "~/components/create-volume-dialog"; import { CreateVolumeDialog } from "~/components/create-volume-dialog";
import { EmptyState } from "~/components/empty-state";
import { StatusDot } from "~/components/status-dot"; import { StatusDot } from "~/components/status-dot";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { VolumeIcon } from "~/components/volume-icon"; import { VolumeIcon } from "~/components/volume-icon";
import type { Route } from "./+types/home"; import type { Route } from "./+types/home";
@@ -58,22 +60,29 @@ export default function Home({ loaderData }: Route.ComponentProps) {
return matchesSearch && matchesStatus && matchesBackend; return matchesSearch && matchesStatus && matchesBackend;
}) || []; }) || [];
const hasNoVolumes = data?.volumes.length === 0;
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
if (hasNoVolumes) {
return (
<Card className="p-0 gap-0">
<EmptyState />
</Card>
);
}
return ( return (
<> <Card className="p-0 gap-0">
<h1 className="text-2xl sm:text-3xl font-bold mb-0 uppercase">Ironmount</h1> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<h2 className="text-xs sm:text-sm font-semibold mb-2 text-muted-foreground"> <span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
Create, manage, monitor, and automate your volumes with ease.
</h2>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mt-4 sm:justify-between">
<span className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Input <Input
className="w-full sm:w-[180px]" className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
placeholder="Search volumes…" placeholder="Search volumes…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]"> <SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -83,7 +92,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={backendFilter} onValueChange={setBackendFilter}> <Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full sm:w-[180px]"> <SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
<SelectValue placeholder="All backends" /> <SelectValue placeholder="All backends" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -93,7 +102,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</SelectContent> </SelectContent>
</Select> </Select>
{(searchQuery || statusFilter || backendFilter) && ( {(searchQuery || statusFilter || backendFilter) && (
<Button variant="outline" size="sm" onClick={clearFilters} className="w-full sm:w-auto"> <Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2" />
Clear filters Clear filters
</Button> </Button>
@@ -101,10 +110,9 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</span> </span>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} /> <CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
</div> </div>
<div className="mt-4 overflow-x-auto"> <div className="overflow-x-auto">
<Table className="border bg-white dark:bg-secondary"> <Table className="border-t">
<TableCaption>A list of your managed volumes.</TableCaption> <TableHeader className="bg-card-header">
<TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead> <TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead> <TableHead className="uppercase text-left">Backend</TableHead>
@@ -113,32 +121,56 @@ export default function Home({ loaderData }: Route.ComponentProps) {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredVolumes.map((volume) => ( {hasNoFilteredVolumes ? (
<TableRow <TableRow>
key={volume.name} <TableCell colSpan={4} className="text-center py-12">
className="hover:bg-accent/50 hover:cursor-pointer" <div className="flex flex-col items-center gap-3">
onClick={() => navigate(`/volumes/${volume.name}`)} <p className="text-muted-foreground">No volumes match your filters.</p>
> <Button onClick={clearFilters} variant="outline" size="sm">
<TableCell className="font-medium">{volume.name}</TableCell> <RotateCcw className="h-4 w-4 mr-2" />
<TableCell> Clear filters
<VolumeIcon backend={volume.type} /> </Button>
</TableCell> </div>
<TableCell className="hidden sm:table-cell">
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
{volume.path}
</span>
<Copy size={10} />
</span>
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ) : (
filteredVolumes.map((volume) => (
<TableRow
key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/volumes/${volume.name}`)}
>
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
<TableCell>
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
{volume.path}
</span>
<Copy size={10} />
</span>
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
</TableCell>
</TableRow>
))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</> <div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
{hasNoFilteredVolumes ? (
"No volumes match filters."
) : (
<span>
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
{filteredVolumes.length > 1 ? "s" : ""}
</span>
)}
</div>
</Card>
); );
} }

View File

@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { loginMutation } from "~/api-client/@tanstack/react-query.gen"; import { loginMutation } from "~/api-client/@tanstack/react-query.gen";
import { GridBackground } from "~/components/grid-background";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
@@ -49,7 +50,7 @@ export default function LoginPage() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <GridBackground className="flex items-center justify-center p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle> <CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
@@ -97,6 +98,6 @@ export default function LoginPage() {
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </GridBackground>
); );
} }

View File

@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { registerMutation } from "~/api-client/@tanstack/react-query.gen"; import { registerMutation } from "~/api-client/@tanstack/react-query.gen";
import { GridBackground } from "~/components/grid-background";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
@@ -60,7 +61,7 @@ export default function OnboardingPage() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <GridBackground className="flex items-center justify-center p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold">Welcome to Ironmount</CardTitle> <CardTitle className="text-2xl font-bold">Welcome to Ironmount</CardTitle>
@@ -78,7 +79,7 @@ export default function OnboardingPage() {
<FormControl> <FormControl>
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus /> <Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
</FormControl> </FormControl>
<FormDescription>Choose a username for the admin account (2-50 characters).</FormDescription> <FormDescription>Choose a username for the admin account</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -127,6 +128,6 @@ export default function OnboardingPage() {
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </GridBackground>
); );
} }

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

View File

@@ -25,14 +25,13 @@
"@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",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.63.0",
@@ -46,11 +45,11 @@
"@react-router/dev": "^7.9.3", "@react-router/dev": "^7.9.3",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/node": "^24.6.2", "@types/node": "^24.6.2",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"lightningcss": "^1.30.2", "lightningcss": "^1.30.2",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tinyglobby": "^0.2.15",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.9", "vite": "^7.1.9",

View File

@@ -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"
} }

View File

@@ -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/docker/volumes/ironmount";
export const DATABASE_URL = "/data/ironmount.db"; export const DATABASE_URL = "/data/ironmount.db";
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";

View File

@@ -1,4 +1,12 @@
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";
@@ -38,3 +46,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;

View File

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

View File

@@ -4,6 +4,7 @@ import { execFile as execFileCb } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { toMessage } from "../../../utils/errors";
const execFile = promisify(execFileCb); const execFile = promisify(execFileCb);
@@ -33,5 +34,18 @@ export const createTestFile = async (path: string): Promise<void> => {
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
await fs.writeFile(testFilePath, "healthcheck"); await fs.writeFile(testFilePath, "healthcheck");
await fs.unlink(testFilePath);
const files = await fs.readdir(path);
await Promise.all(
files.map(async (file) => {
if (file.startsWith(".healthcheck-")) {
const filePath = npath.join(path, file);
try {
await fs.unlink(filePath);
} catch (err) {
logger.warn(`Failed to stat or unlink file ${filePath}: ${toMessage(err)}`);
}
}
}),
);
}; };

View File

@@ -4,8 +4,11 @@ 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 { volumeService } from "../volumes/volume.service"; import { volumeService } from "../volumes/volume.service";
import { restic } from "../../utils/restic";
export const startup = async () => { export const startup = async () => {
await restic.ensurePassfile();
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"),
@@ -21,7 +24,7 @@ export const startup = async () => {
existingTasks.forEach(async (task) => await task.destroy()); existingTasks.forEach(async (task) => await task.destroy());
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")),

View File

@@ -0,0 +1 @@

View File

@@ -9,7 +9,9 @@ import {
getVolumeDto, getVolumeDto,
healthCheckDto, healthCheckDto,
type ListContainersResponseDto, type ListContainersResponseDto,
type ListFilesResponseDto,
type ListVolumesResponseDto, type ListVolumesResponseDto,
listFilesDto,
listVolumesDto, listVolumesDto,
mountVolumeDto, mountVolumeDto,
testConnectionBody, testConnectionBody,
@@ -118,4 +120,16 @@ export const volumeController = new Hono()
const { error, status } = await volumeService.checkHealth(name); const { error, status } = await volumeService.checkHealth(name);
return c.json({ error, status }, 200); return c.json({ error, status }, 200);
})
.get("/:name/files", listFilesDto, async (c) => {
const { name } = c.req.param();
const subPath = c.req.query("path");
const result = await volumeService.listFiles(name, subPath);
const response = {
files: result.files,
path: result.path,
} satisfies ListFilesResponseDto;
return c.json(response, 200);
}); });

View File

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

View File

@@ -253,6 +253,69 @@ const getContainersUsingVolume = async (name: string) => {
return { containers: usingContainers }; return { containers: usingContainers };
}; };
const listFiles = async (name: string, subPath?: string) => {
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.name, name),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
if (volume.status !== "mounted") {
throw new InternalServerError("Volume is not mounted");
}
const requestedPath = subPath ? path.join(volume.path, subPath) : volume.path;
const normalizedPath = path.normalize(requestedPath);
if (!normalizedPath.startsWith(volume.path)) {
throw new InternalServerError("Invalid path");
}
try {
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(normalizedPath, entry.name);
const relativePath = path.relative(volume.path, fullPath);
try {
const stats = await fs.stat(fullPath);
return {
name: entry.name,
path: `/${relativePath}`,
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
size: entry.isFile() ? stats.size : undefined,
modifiedAt: stats.mtimeMs,
};
} catch {
return {
name: entry.name,
path: `/${relativePath}`,
type: entry.isDirectory() ? ("directory" as const) : ("file" as const),
size: undefined,
modifiedAt: undefined,
};
}
}),
);
return {
files: files.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
}),
path: subPath || "/",
};
} catch (error) {
throw new InternalServerError(`Failed to list files: ${toMessage(error)}`);
}
};
export const volumeService = { export const volumeService = {
listVolumes, listVolumes,
createVolume, createVolume,
@@ -264,4 +327,5 @@ export const volumeService = {
unmountVolume, unmountVolume,
checkHealth, checkHealth,
getContainersUsingVolume, getContainersUsingVolume,
listFiles,
}; };

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

View File

@@ -27,14 +27,13 @@
"@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",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.63.0",
@@ -48,11 +47,11 @@
"@react-router/dev": "^7.9.3", "@react-router/dev": "^7.9.3",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/node": "^24.6.2", "@types/node": "^24.6.2",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"lightningcss": "^1.30.2", "lightningcss": "^1.30.2",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tinyglobby": "^0.2.15",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.9", "vite": "^7.1.9",
@@ -80,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",
}, },
@@ -97,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=="],
@@ -513,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=="],
@@ -543,8 +544,6 @@
"@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="], "@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="],
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
"@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
"@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="], "@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="],
@@ -573,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=="],
@@ -607,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=="],
@@ -1033,8 +1032,6 @@
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"proc-log": ["proc-log@3.0.0", "", {}, "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A=="], "proc-log": ["proc-log@3.0.0", "", {}, "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A=="],
"promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="],
@@ -1299,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=="],
@@ -1337,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=="],
@@ -1433,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=="],
@@ -1469,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=="],

View File

@@ -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",

View File

@@ -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