Compare commits

...

17 Commits

Author SHA1 Message Date
Nicolas Meienberger
2b0fea9645 fix(mounts): use bun shell instead of execFile 2025-11-10 06:52:14 +01:00
Nicolas Meienberger
e9eeda304b chore: update readme with new version 2025-11-09 15:37:16 +01:00
Nicolas Meienberger
4ddc45a74f style(create-schedule): fix explorer width on mobile 2025-11-09 14:19:34 +01:00
Nicolas Meienberger
2aa90ec44d feat: exclude patterns 2025-11-09 14:09:49 +01:00
Nicolas Meienberger
dd36397346 Merge branch 'steveiliop56-main' 2025-11-09 12:34:44 +01:00
Nicolas Meienberger
2ec8d4c1dd chore: small fixes 2025-11-09 12:34:12 +01:00
Nicolas Meienberger
4b981bdcac style: use card in backup progress waiting 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
5e908dc945 feat: backup progress 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
5f35cfd4c2 feat: throttle logs during backup 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
1152939373 feat: repository doctor always visible 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
94398f81bf style: use card in backup progress waiting 2025-11-09 12:25:15 +01:00
Stavros
db0d153610 chore: format 2025-11-09 13:16:14 +02:00
Nicolas Meienberger
5ff48f4d5d feat: backup progress 2025-11-09 12:11:00 +01:00
Stavros
ffca433a43 fix: accessibility and responsiveness fixes 2025-11-09 13:04:14 +02:00
Nicolas Meienberger
4389029ba5 feat: throttle logs during backup 2025-11-09 11:33:14 +01:00
Nicolas Meienberger
927db77f60 feat: repository doctor always visible 2025-11-09 11:31:44 +01:00
Nicolas Meienberger
3e80850396 fix(backup): only keep last line of stdout in memory 2025-11-08 23:41:29 +01:00
34 changed files with 720 additions and 383 deletions

View File

@@ -23,3 +23,4 @@
!LICENSE
!NOTICES.md
!LICENSES/**

View File

@@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
privileged: true
@@ -67,7 +67,7 @@ If you want to track a local directory on the same server where Ironmount is run
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -124,24 +124,21 @@ Ironmount allows you to easily restore your data from backups. To restore data,
Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
In order to enable this feature, you need to run Ironmount with privileged mode and mount /proc from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
In order to enable this feature, you need to change your bind mount `/var/lib/ironmount` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
- cap_add:
- - SYS_ADMIN
+ privileged: true
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
volumes:
- /var/lib/ironmount:/var/lib/ironmount
+ - /proc:/host/proc
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
```
Restart the Ironmount container to apply the changes:
@@ -155,24 +152,23 @@ docker compose up -d
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
In order to enable this feature, you need to run Ironmount with privileged mode and mount several items from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
In order to enable this feature, you need to run Ironmount with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.5.0
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
- cap_add:
- - SYS_ADMIN
+ privileged: true
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
volumes:
- /var/lib/ironmount:/var/lib/ironmount
+ - /proc:/host/proc
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
+ - /run/docker/plugins:/run/docker/plugins
+ - /var/run/docker.sock:/var/run/docker.sock
```

View File

@@ -5,6 +5,7 @@
@custom-variant dark (&:is(.dark *));
@theme {
--breakpoint-xs: 32rem;
--font-sans:
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
@@ -12,16 +13,16 @@
html,
body {
@apply bg-white dark:bg-[#131313];
overflow-x: hidden;
width: 100%;
position: relative;
overscroll-behavior: none;
scrollbar-width: thin;
}
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
body {
@apply bg-[#131313];
min-height: 100dvh;
}
.main-content {
@@ -70,8 +71,6 @@ body {
}
:root {
color-scheme: dark;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
@@ -109,6 +108,8 @@ body {
}
.dark {
color-scheme: dark;
--background: #131313;
--foreground: oklch(0.985 0 0);
--card: #131313;

View File

@@ -9,7 +9,7 @@ type AuthLayoutProps = {
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
return (
<div className="flex min-h-screen">
<div className="flex mt-[25%] lg:mt-0 lg:min-h-screen">
<div className="flex flex-1 items-center justify-center bg-background p-8">
<div className="w-full max-w-md space-y-8">
<div className="flex items-center gap-3">
@@ -26,7 +26,7 @@ export function AuthLayout({ title, description, children }: AuthLayoutProps) {
</div>
</div>
<div
className="hidden lg:block lg:flex-1 dither-xl bg-cover bg-center"
className="hidden lg:block lg:flex-1 dither-lg bg-cover bg-center"
style={{ backgroundImage: "url(/images/background.jpg)" }}
/>
</div>

View File

@@ -76,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
</header>
<div className="main-content flex-1 overflow-y-auto">
<GridBackground>
<main className="flex flex-col p-2 pt-2 sm:p-8 sm:pt-6 mx-auto">
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
<Outlet />
</main>
</GridBackground>

View File

@@ -20,7 +20,12 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
)}
>
<span>{isOn ? enabledLabel : disabledLabel}</span>
<Switch disabled={disabled} checked={isOn} onCheckedChange={toggle} />
<Switch
disabled={disabled}
checked={isOn}
onCheckedChange={toggle}
aria-label={isOn ? `Toggle ${enabledLabel}` : `Toggle ${disabledLabel}`}
/>
</div>
);
};

View File

@@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { ByteSize } from "~/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
import { formatDuration } from "~/utils/utils";
type Snapshot = ListSnapshotsResponse[number];
@@ -62,9 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatSnapshotDuration(snapshot.duration / 1000)}
</span>
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">

View File

@@ -38,7 +38,10 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
)}
/>
)}
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
<span
aria-label={status}
className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)}
/>
</span>
</TooltipTrigger>
<TooltipContent>

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "~/lib/utils";
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "~/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -5,19 +5,33 @@ type ServerEventType =
| "connected"
| "heartbeat"
| "backup:started"
| "backup:progress"
| "backup:completed"
| "volume:mounted"
| "volume:unmounted"
| "volume:updated";
interface BackupEvent {
export interface BackupEvent {
scheduleId: number;
volumeName: string;
repositoryName: string;
status?: "success" | "error";
}
interface VolumeEvent {
export interface BackupProgressEvent {
scheduleId: number;
volumeName: string;
repositoryName: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
current_files: string[];
}
export interface VolumeEvent {
volumeName: string;
}
@@ -51,6 +65,14 @@ export function useServerEvents() {
});
});
eventSource.addEventListener("backup:progress", (e) => {
const data = JSON.parse(e.data) as BackupProgressEvent;
handlersRef.current.get("backup:progress")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("backup:completed", (e) => {
const data = JSON.parse(e.data) as BackupEvent;
console.log("[SSE] Backup completed:", data);

View File

@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { ByteSize, formatBytes } from "~/components/bytes-size";
import { Card } from "~/components/ui/card";
import { Progress } from "~/components/ui/progress";
import { type BackupProgressEvent, useServerEvents } from "~/hooks/use-server-events";
import { formatDuration } from "~/utils/utils";
type Props = {
scheduleId: number;
};
export const BackupProgressCard = ({ scheduleId }: Props) => {
const { addEventListener } = useServerEvents();
const [progress, setProgress] = useState<BackupProgressEvent | null>(null);
useEffect(() => {
const unsubscribe = addEventListener("backup:progress", (data) => {
const progressData = data as BackupProgressEvent;
if (progressData.scheduleId === scheduleId) {
setProgress(progressData);
}
});
const unsubscribeComplete = addEventListener("backup:completed", (data) => {
const completedData = data as { scheduleId: number };
if (completedData.scheduleId === scheduleId) {
setProgress(null);
}
});
return () => {
unsubscribe();
unsubscribeComplete();
};
}, [addEventListener, scheduleId]);
if (!progress) {
return (
<Card className="p-4">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="font-medium">Backup in progress</span>
</div>
</Card>
);
}
const percentDone = Math.round(progress.percent_done * 100);
const currentFile = progress.current_files[0] || "";
const fileName = currentFile.split("/").pop() || currentFile;
const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="font-medium">Backup in progress</span>
</div>
<span className="text-sm font-medium text-primary">{percentDone}%</span>
</div>
<Progress value={percentDone} className="h-2" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs uppercase text-muted-foreground">Files</p>
<p className="font-medium">
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Data</p>
<p className="font-medium">
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} />
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Speed</p>
<p className="font-medium">
{progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."}
</p>
</div>
</div>
{fileName && (
<div className="pt-2 border-t border-border">
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile}>
{fileName}
</p>
</div>
)}
</Card>
);
};

View File

@@ -9,13 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/com
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea";
import { VolumeFileBrowser } from "~/components/volume-file-browser";
import type { BackupSchedule, Volume } from "~/lib/types";
import { deepClean } from "~/utils/object";
const formSchema = type({
const internalFormSchema = type({
repositoryId: "string",
excludePatterns: "string[]?",
excludePatternsText: "string?",
includePatterns: "string[]?",
frequency: "string",
dailyTime: "string?",
@@ -27,7 +28,7 @@ const formSchema = type({
keepMonthly: "number?",
keepYearly: "number?",
});
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
export const weeklyDays = [
{ label: "Monday", value: "1" },
@@ -39,7 +40,11 @@ export const weeklyDays = [
{ label: "Sunday", value: "0" },
];
export type BackupScheduleFormValues = typeof formSchema.infer;
type InternalFormValues = typeof internalFormSchema.infer;
export type BackupScheduleFormValues = Omit<InternalFormValues, "excludePatternsText"> & {
excludePatterns?: string[];
};
type Props = {
volume: Volume;
@@ -50,7 +55,7 @@ type Props = {
formId: string;
};
const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => {
const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
if (!schedule) {
return undefined;
}
@@ -72,16 +77,36 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo
dailyTime,
weeklyDay,
includePatterns: schedule.includePatterns || undefined,
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
...schedule.retentionPolicy,
};
};
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<BackupScheduleFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
const form = useForm<InternalFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues),
});
const handleSubmit = useCallback(
(data: InternalFormValues) => {
// Convert excludePatternsText string to excludePatterns array
const { excludePatternsText, ...rest } = data;
const excludePatterns = excludePatternsText
? excludePatternsText
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
: undefined;
onSubmit({
...rest,
excludePatterns,
});
},
[onSubmit],
);
const { data: repositoriesData } = useQuery({
...listRepositoriesOptions(),
});
@@ -102,7 +127,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(handleSubmit)}
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]"
id={formId}
>
@@ -232,7 +257,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
onSelectionChange={handleSelectionChange}
withCheckboxes={true}
foldersOnly={true}
className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
/>
{selectedPaths.size > 0 && (
<div className="mt-4">
@@ -249,6 +274,47 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Exclude patterns</CardTitle>
<CardDescription>
Optionally specify patterns to exclude from backups. Enter one pattern per line (e.g., *.tmp,
node_modules/**, .cache/).
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="excludePatternsText"
render={({ field }) => (
<FormItem>
<FormLabel>Exclusion patterns</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="*.tmp&#10;node_modules/**&#10;.cache/&#10;*.log"
className="font-mono text-sm min-h-[120px]"
/>
</FormControl>
<FormDescription>
Patterns support glob syntax. See&nbsp;
<a
href="https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Restic documentation
</a>
&nbsp;for more details.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retention policy</CardTitle>
@@ -408,6 +474,33 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
</p>
</div>
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
<div>
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
<div className="flex flex-col gap-1">
{formValues.includePatterns.map((path) => (
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{path}
</span>
))}
</div>
</div>
)}
{formValues.excludePatternsText && (
<div>
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
<div className="flex flex-col gap-1">
{formValues.excludePatternsText
.split("\n")
.filter(Boolean)
.map((pattern) => (
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{pattern.trim()}
</span>
))}
</div>
</div>
)}
<div>
<p className="text-xs uppercase text-muted-foreground">Retention</p>
<p className="font-medium">

View File

@@ -13,6 +13,7 @@ import {
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import type { BackupSchedule } from "~/lib/types";
import { BackupProgressCard } from "./backup-progress-card";
type Props = {
schedule: BackupSchedule;
@@ -144,6 +145,8 @@ export const ScheduleSummary = (props: Props) => {
</CardContent>
</Card>
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -1,9 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { redirect, useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useState, useEffect } from "react";
import {
deleteRepositoryMutation,
doctorRepositoryMutation,
getRepositoryOptions,
listSnapshotsOptions,
} from "~/api-client/@tanstack/react-query.gen";
@@ -24,6 +25,7 @@ import { cn } from "~/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { Loader2 } from "lucide-react";
export function meta({ params }: Route.MetaArgs) {
return [
@@ -38,10 +40,13 @@ export function meta({ params }: Route.MetaArgs) {
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const repository = await getRepository({ path: { name: params.name ?? "" } });
if (repository.data) return repository.data;
return redirect("/repositories");
};
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>();
const [showDoctorResults, setShowDoctorResults] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -50,17 +55,15 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
const activeTab = searchParams.get("tab") || "info";
const { data } = useQuery({
...getRepositoryOptions({ path: { name: name ?? "" } }),
...getRepositoryOptions({ path: { name: loaderData.name } }),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
useEffect(() => {
if (name) {
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } }));
}
}, [name, queryClient]);
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
}, [queryClient, data.name]);
const deleteRepo = useMutation({
...deleteRepositoryMutation(),
@@ -75,39 +78,78 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
},
});
const doctorMutation = useMutation({
...doctorRepositoryMutation(),
onSuccess: (data) => {
if (data) {
setShowDoctorResults(true);
if (data.success) {
toast.success("Repository doctor completed successfully");
} else {
toast.warning("Doctor completed with some issues", {
description: "Check the details for more information",
richColors: true,
});
}
}
},
onError: (error) => {
toast.error("Failed to run doctor", {
description: parseError(error)?.message,
});
},
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteRepo.mutate({ path: { name: name ?? "" } });
deleteRepo.mutate({ path: { name: data.name } });
};
if (!name) {
return <div>Repository not found</div>;
}
if (!data) {
return <div>Loading...</div>;
}
const getStepLabel = (step: string) => {
switch (step) {
case "unlock":
return "Unlock Repository";
case "check":
return "Check Repository";
case "repair_index":
return "Repair Index";
case "recheck":
return "Re-check Repository";
default:
return step;
}
};
return (
<>
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
{
"bg-green-500/10 text-green-500": data.status === "healthy",
"bg-red-500/10 text-red-500": data.status === "error",
},
)}
>
{data.status || "unknown"}
</span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
</div>
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
<span
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
"bg-green-500/10 text-green-500": data.status === "healthy",
"bg-red-500/10 text-red-500": data.status === "error",
})}
>
{data.status || "unknown"}
</span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
</div>
<div className="flex gap-4">
<Button
onClick={() => doctorMutation.mutate({ path: { name: data.name } })}
disabled={doctorMutation.isPending}
variant={"outline"}
>
{doctorMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Running Doctor...
</>
) : (
"Run Doctor"
)}
</Button>
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
Delete
</Button>
@@ -132,8 +174,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
<AlertDialogHeader>
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the repository <strong>{name}</strong>? This action cannot be undone and
will remove all backup data.
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
and will remove all backup data.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
@@ -147,6 +189,46 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
</div>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
</AlertDialogHeader>
{doctorMutation.data && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{doctorMutation.data.steps.map((step) => (
<div
key={step.step}
className={cn("border rounded-md p-3", {
"bg-green-500/10 border-green-500/20": step.success,
"bg-yellow-500/10 border-yellow-500/20": !step.success,
})}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
<span
className={cn("text-xs px-2 py-1 rounded", {
"bg-green-500/20 text-green-500": step.success,
"bg-yellow-500/20 text-yellow-500": !step.success,
})}
>
{step.success ? "Success" : "Warning"}
</span>
</div>
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
</div>
))}
</div>
)}
<div className="flex justify-end">
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
</div>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -18,14 +18,19 @@ export function meta({ params }: Route.MetaArgs) {
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } });
const snapshot = await getSnapshotDetails({
path: { name: params.name, snapshotId: params.snapshotId },
});
if (snapshot.data) return snapshot.data;
return redirect("/repositories");
};
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>();
const { name, snapshotId } = useParams<{
name: string;
snapshotId: string;
}>();
const { data } = useQuery({
...listSnapshotFilesOptions({
@@ -64,11 +69,11 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground">Snapshot ID:</span>
<p className="font-mono">{data.snapshot.id}</p>
<p className="font-mono break-all">{data.snapshot.id}</p>
</div>
<div>
<span className="text-muted-foreground">Short ID:</span>
<p className="font-mono">{data.snapshot.short_id}</p>
<p className="font-mono break-all">{data.snapshot.short_id}</p>
</div>
<div>
<span className="text-muted-foreground">Hostname:</span>
@@ -82,7 +87,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<span className="text-muted-foreground">Paths:</span>
<div className="space-y-1 mt-1">
{data.snapshot.paths.map((path) => (
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded">
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
{path}
</p>
))}

View File

@@ -1,175 +1,63 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Card } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Loader2 } from "lucide-react";
import type { Repository } from "~/lib/types";
import { parseError } from "~/lib/errors";
import { doctorRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
import { cn } from "~/lib/utils";
type Props = {
repository: Repository;
};
export const RepositoryInfoTabContent = ({ repository }: Props) => {
const [showDoctorResults, setShowDoctorResults] = useState(false);
const doctorMutation = useMutation({
...doctorRepositoryMutation(),
onSuccess: (data) => {
if (data) {
setShowDoctorResults(true);
if (data.success) {
toast.success("Repository doctor completed successfully");
} else {
toast.warning("Doctor completed with some issues", {
description: "Check the details for more information",
richColors: true,
});
}
}
},
onError: (error) => {
toast.error("Failed to run doctor", {
description: parseError(error)?.message,
});
},
});
const handleDoctor = () => {
doctorMutation.mutate({ path: { name: repository.name } });
};
const getStepLabel = (step: string) => {
switch (step) {
case "unlock":
return "Unlock Repository";
case "check":
return "Check Repository";
case "repair_index":
return "Repair Index";
case "recheck":
return "Re-check Repository";
default:
return step;
}
};
return (
<>
<Card className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
{repository.lastError && (
<Card className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
<Button onClick={handleDoctor} disabled={doctorMutation.isPending} variant={"outline"} size="sm">
{doctorMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Running Doctor...
</>
) : (
"Run Doctor"
)}
</Button>
</div>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p>
</div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
</Card>
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
</AlertDialogHeader>
{doctorMutation.data && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{doctorMutation.data.steps.map((step) => (
<div
key={step.step}
className={cn("border rounded-md p-3", {
"bg-green-500/10 border-green-500/20": step.success,
"bg-yellow-500/10 border-yellow-500/20": !step.success,
})}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
<span
className={cn("text-xs px-2 py-1 rounded", {
"bg-green-500/20 text-green-500": step.success,
"bg-yellow-500/20 text-yellow-500": !step.success,
})}
>
{step.success ? "Success" : "Warning"}
</span>
</div>
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
</div>
))}
{repository.lastError && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
</div>
)}
<div className="flex justify-end">
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p>
</div>
</div>
</AlertDialogContent>
</AlertDialog>
</>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div>
</div>
</div>
</Card>
);
};

View File

@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { intervalToDuration } from "date-fns";
import { Database } from "lucide-react";
import { useState } from "react";
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
@@ -15,18 +14,6 @@ type Props = {
repository: Repository;
};
export const formatSnapshotDuration = (seconds: number) => {
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
const parts: string[] = [];
if (duration.days) parts.push(`${duration.days}d`);
if (duration.hours) parts.push(`${duration.hours}h`);
if (duration.minutes) parts.push(`${duration.minutes}m`);
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
return parts.join(" ");
};
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
const [searchQuery, setSearchQuery] = useState("");

View File

@@ -118,14 +118,12 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
return (
<>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
<span className="flex items-center gap-2">
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
</span>
<VolumeIcon size={14} backend={volume?.config.backend} />
</div>
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
<span className="flex items-center gap-2">
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
</span>
<VolumeIcon size={14} backend={volume?.config.backend} />
</div>
<div className="flex gap-4">
<Button

View File

@@ -38,7 +38,7 @@ const queryClient = new QueryClient({
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" style={{ colorScheme: "dark" }} className="dark">
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
@@ -52,7 +52,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links />
</head>
<QueryClientProvider client={queryClient}>
<body>
<body className="dark">
{children}
<Toaster />
<ScrollRestoration />

View File

@@ -1,3 +1,5 @@
import { intervalToDuration } from "date-fns";
export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => {
if (frequency === "hourly") {
return "0 * * * *";
@@ -15,3 +17,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`;
};
export const formatDuration = (seconds: number) => {
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
const parts: string[] = [];
if (duration.days) parts.push(`${duration.days}d`);
if (duration.hours) parts.push(`${duration.hours}h`);
if (duration.minutes) parts.push(`${duration.minutes}m`);
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
return parts.join(" ");
};

View File

@@ -15,6 +15,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",

View File

@@ -29,5 +29,6 @@ export default defineConfig({
changeOrigin: true,
},
},
allowedHosts: true,
},
});

View File

@@ -6,9 +6,8 @@ await Bun.build({
sourcemap: true,
minify: {
whitespace: true,
identifiers: true,
identifiers: false,
syntax: true,
keepNames: true,
},
external: ["ssh2"],
});

View File

@@ -15,6 +15,7 @@
"dockerode": "^4.0.8",
"dotenv": "^17.2.1",
"drizzle-orm": "^0.44.6",
"es-toolkit": "^1.41.0",
"hono": "^4.9.2",
"hono-openapi": "^1.1.0",
"http-errors-enhanced": "^3.0.2",

View File

@@ -4,7 +4,6 @@ import { logger } from "../utils/logger";
export type SystemCapabilities = {
docker: boolean;
hostProc: boolean;
};
let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
@@ -29,7 +28,6 @@ export async function getCapabilities(): Promise<SystemCapabilities> {
async function detectCapabilities(): Promise<SystemCapabilities> {
return {
docker: await detectDocker(),
hostProc: await detectHostProc(),
};
}
@@ -55,23 +53,3 @@ async function detectDocker(): Promise<boolean> {
return false;
}
}
/**
* Checks if host proc is available by attempting to access /host/proc/1/ns/mnt
* This allows using nsenter to execute mount commands in the host namespace
*/
async function detectHostProc(): Promise<boolean> {
try {
await fs.access("/host/proc/1/ns/mnt");
logger.info("Host proc capability: enabled");
return true;
} catch (_) {
logger.warn(
"Host proc capability: disabled. " +
"To enable: mount /proc:/host/proc:ro in docker-compose.yml. " +
"Mounts will be executed in container namespace instead of host namespace.",
);
return false;
}
}

View File

@@ -6,6 +6,18 @@ import type { TypedEmitter } from "tiny-typed-emitter";
*/
interface ServerEvents {
"backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void;
"backup:progress": (data: {
scheduleId: number;
volumeName: string;
repositoryName: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
current_files: string[];
}) => void;
"backup:completed": (data: {
scheduleId: number;
volumeName: string;

View File

@@ -1,31 +1,14 @@
import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs/promises";
import * as npath from "node:path";
import { promisify } from "node:util";
import { getCapabilities } from "../../../core/capabilities";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
const execFile = promisify(execFileCb);
import { $ } from "bun";
export const executeMount = async (args: string[]): Promise<void> => {
const capabilities = await getCapabilities();
let stderr: string | undefined;
if (capabilities.hostProc) {
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
} else {
const result = await execFile("mount", args, {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
}
const result = await $`mount ${args}`.nothrow();
stderr = result.stderr.toString();
if (stderr?.trim()) {
logger.warn(stderr.trim());
@@ -33,22 +16,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
};
export const executeUnmount = async (path: string): Promise<void> => {
const capabilities = await getCapabilities();
let stderr: string | undefined;
if (capabilities.hostProc) {
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
} else {
const result = await execFile("umount", ["-l", "-f", path], {
timeout: OPERATION_TIMEOUT,
maxBuffer: 1024 * 1024,
});
stderr = result.stderr;
}
const result = await $`umount -l -f ${path}`.nothrow();
stderr = result.stderr.toString();
if (stderr?.trim()) {
logger.warn(stderr.trim());

View File

@@ -197,7 +197,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await db
.update(backupSchedulesTable)
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now() })
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null })
.where(eq(backupSchedulesTable.id, scheduleId));
const abortController = new AbortController();
@@ -224,7 +224,17 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.include = schedule.includePatterns;
}
await restic.backup(repository.config, volumePath, backupOptions);
await restic.backup(repository.config, volumePath, {
...backupOptions,
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
...progress,
});
},
});
if (schedule.retentionPolicy) {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });

View File

@@ -19,6 +19,24 @@ export const eventsController = new Hono().get("/", (c) => {
});
};
const onBackupProgress = (data: {
scheduleId: number;
volumeName: string;
repositoryName: string;
secondsElapsed: number;
percentDone: number;
totalFiles: number;
filesDone: number;
totalBytes: number;
bytesDone: number;
currentFiles: string[];
}) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "backup:progress",
});
};
const onBackupCompleted = (data: {
scheduleId: number;
volumeName: string;
@@ -53,6 +71,7 @@ export const eventsController = new Hono().get("/", (c) => {
};
serverEvents.on("backup:started", onBackupStarted);
serverEvents.on("backup:progress", onBackupProgress);
serverEvents.on("backup:completed", onBackupCompleted);
serverEvents.on("volume:mounted", onVolumeMounted);
serverEvents.on("volume:unmounted", onVolumeUnmounted);
@@ -64,6 +83,7 @@ export const eventsController = new Hono().get("/", (c) => {
logger.info("Client disconnected from SSE endpoint");
keepAlive = false;
serverEvents.off("backup:started", onBackupStarted);
serverEvents.off("backup:progress", onBackupProgress);
serverEvents.off("backup:completed", onBackupCompleted);
serverEvents.off("volume:mounted", onVolumeMounted);
serverEvents.off("volume:unmounted", onVolumeUnmounted);

View File

@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { throttle } from "es-toolkit";
import type { RepositoryConfig } from "@ironmount/schemas/restic";
import { type } from "arktype";
import { $ } from "bun";
@@ -9,6 +9,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants";
import { logger } from "./logger";
import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn";
const backupOutputSchema = type({
message_type: "'summary'",
@@ -81,7 +82,7 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
const buildEnv = async (config: RepositoryConfig) => {
const env: Record<string, string> = {
RESTIC_CACHE_DIR: "/tmp/restic-cache",
RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache",
RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE,
};
@@ -112,10 +113,29 @@ const init = async (config: RepositoryConfig) => {
return { success: true, error: null };
};
const backupProgressSchema = type({
message_type: "'status'",
seconds_elapsed: "number",
percent_done: "number",
total_files: "number",
files_done: "number",
total_bytes: "number",
bytes_done: "number",
current_files: "string[]",
});
export type BackupProgress = typeof backupProgressSchema.infer;
const backup = async (
config: RepositoryConfig,
source: string,
options?: { exclude?: string[]; include?: string[]; tags?: string[]; signal?: AbortSignal },
options?: {
exclude?: string[];
include?: string[];
tags?: string[];
signal?: AbortSignal;
onProgress?: (progress: BackupProgress) => void;
},
) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
@@ -149,68 +169,64 @@ const backup = async (
args.push("--json");
return new Promise((resolve, reject) => {
const child = spawn("restic", args, {
env: { ...process.env, ...env },
signal: options?.signal,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += data.toString();
});
child.on("error", async (error) => {
if (includeFile) {
await fs.unlink(includeFile).catch(() => {});
}
if (error.name === "AbortError") {
logger.info("Restic backup process was aborted");
reject(error);
} else {
logger.error(`Restic backup process error: ${error.message}`);
reject(new Error(`Restic backup process error: ${error.message}`));
}
});
child.on("close", async (code) => {
if (includeFile) {
await fs.unlink(includeFile).catch(() => {});
}
if (code !== 0) {
logger.error(`Restic backup failed with exit code ${code}: ${stderr}`);
reject(new Error(`Restic backup failed: ${stderr}`));
return;
}
const logData = throttle((data: string) => {
logger.info(data.trim());
}, 5000);
const streamProgress = throttle((data: string) => {
if (options?.onProgress) {
try {
const outputLines = stdout.trim().split("\n");
const lastLine = outputLines[outputLines.length - 1];
const resSummary = JSON.parse(lastLine ?? "{}");
const result = backupOutputSchema(resSummary);
if (result instanceof type.errors) {
logger.error(`Restic backup output validation failed: ${result}`);
reject(new Error(`Restic backup output validation failed: ${result}`));
return;
const jsonData = JSON.parse(data);
const progress = backupProgressSchema(jsonData);
if (!(progress instanceof type.errors)) {
options.onProgress(progress);
}
resolve(result);
} catch (error) {
logger.error(`Failed to parse restic backup output: ${error}`);
reject(new Error(`Failed to parse restic backup output: ${error}`));
} catch (_) {
// Ignore JSON parse errors for non-JSON lines
}
});
}
}, 1000);
let stdout = "";
const res = await safeSpawn({
command: "restic",
args,
env,
signal: options?.signal,
onStdout: (data) => {
stdout = data;
logData(data);
if (options?.onProgress) {
streamProgress(data);
}
},
onStderr: (error) => {
logger.error(error.trim());
},
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
},
});
if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`);
throw new Error(`Restic backup failed: ${res.stderr}`);
}
const lastLine = stdout.trim();
const resSummary = JSON.parse(lastLine ?? "{}");
const result = backupOutputSchema(resSummary);
if (result instanceof type.errors) {
logger.error(`Restic backup output validation failed: ${result}`);
throw new Error(`Restic backup output validation failed: ${result}`);
}
return result;
};
const restoreOutputSchema = type({
@@ -371,7 +387,6 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
args.push("--prune");
args.push("--json");
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
const res = await $`restic ${args}`.env(env).nothrow();
if (res.exitCode !== 0) {
@@ -466,7 +481,7 @@ const unlock = async (config: RepositoryConfig) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const res = await $`restic unlock --repo ${repoUrl} --json`.env(env).nothrow();
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`);
@@ -502,7 +517,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
};
}
const hasErrors = stdout.includes("error") || stdout.includes("Fatal");
const hasErrors = stdout.includes("Fatal");
logger.info(`Restic check completed for repository: ${repoUrl}`);
return {

View File

@@ -0,0 +1,79 @@
import { spawn } from "node:child_process";
interface Params {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
onStdout?: (data: string) => void;
onStderr?: (error: string) => void;
onError?: (error: Error) => Promise<void> | void;
onClose?: (code: number | null) => Promise<void> | void;
finally?: () => Promise<void> | void;
}
type SpawnResult = {
exitCode: number;
stdout: string;
stderr: string;
};
export const safeSpawn = (params: Params) => {
const { command, args, env = {}, signal, ...callbacks } = params;
return new Promise<SpawnResult>((resolve) => {
let stdoutData = "";
let stderrData = "";
const child = spawn(command, args, {
env: { ...process.env, ...env },
signal: signal,
});
child.stdout.on("data", (data) => {
if (callbacks.onStdout) {
callbacks.onStdout(data.toString());
} else {
stdoutData += data.toString();
}
});
child.stderr.on("data", (data) => {
if (callbacks.onStderr) {
callbacks.onStderr(data.toString());
} else {
stderrData += data.toString();
}
});
child.on("error", async (error) => {
if (callbacks.onError) {
await callbacks.onError(error);
}
if (callbacks.finally) {
await callbacks.finally();
}
resolve({
exitCode: -1,
stdout: stdoutData,
stderr: stderrData,
});
});
child.on("close", async (code) => {
if (callbacks.onClose) {
await callbacks.onClose(code);
}
if (callbacks.finally) {
await callbacks.finally();
}
resolve({
exitCode: code === null ? -1 : code,
stdout: stdoutData,
stderr: stderrData,
});
});
});
};

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
@@ -76,6 +77,7 @@
"dockerode": "^4.0.8",
"dotenv": "^17.2.1",
"drizzle-orm": "^0.44.6",
"es-toolkit": "^1.41.0",
"hono": "^4.9.2",
"hono-openapi": "^1.1.0",
"http-errors-enhanced": "^3.0.2",
@@ -376,6 +378,8 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
@@ -812,7 +816,7 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
"es-toolkit": ["es-toolkit@1.41.0", "", {}, "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA=="],
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
@@ -1484,6 +1488,10 @@
"@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
@@ -1594,6 +1602,8 @@
"protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
"recharts/es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -1660,6 +1670,8 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],

View File

@@ -34,4 +34,6 @@ services:
ports:
- "4096:4096"
volumes:
- /var/lib/ironmount/:/var/lib/ironmount/
- /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock