Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/client/components/restore-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { PathSelector } from "~/client/components/path-selector";
import { FileTree } from "~/client/components/file-tree";
import { RestoreProgress } from "~/client/components/restore-progress";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
Expand Down Expand Up @@ -173,6 +174,8 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re

<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-6">
{isRestoring && <RestoreProgress repositoryId={repository.id} snapshotId={snapshotId} />}

<Card>
<CardHeader>
<CardTitle>Restore Location</CardTitle>
Expand Down
90 changes: 90 additions & 0 deletions app/client/components/restore-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { ByteSize, formatBytes } from "~/client/components/bytes-size";
import { Card } from "~/client/components/ui/card";
import { Progress } from "~/client/components/ui/progress";
import { type RestoreProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { formatDuration } from "~/utils/utils";

type Props = {
repositoryId: string;
snapshotId: string;
};

export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
const { addEventListener } = useServerEvents();
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);

useEffect(() => {
const unsubscribe = addEventListener("restore:progress", (data) => {
const progressData = data as RestoreProgressEvent;
if (progressData.repositoryId === repositoryId && progressData.snapshotId === snapshotId) {
setProgress(progressData);
}
});

const unsubscribeComplete = addEventListener("restore:completed", (data) => {
const completedData = data as { repositoryId: string; snapshotId: string };
if (completedData.repositoryId === repositoryId && completedData.snapshotId === snapshotId) {
setProgress(null);
}
});

return () => {
unsubscribe();
unsubscribeComplete();
};
}, [addEventListener, repositoryId, snapshotId]);

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">Restore in progress</span>
</div>
</Card>
);
}

const percentDone = Math.round(progress.percent_done * 100);
const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);

return (
<Card className="p-4">
<div className="flex items-center justify-between mb-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">Restore in progress</span>
</div>
<span className="text-sm font-medium text-primary">{percentDone}%</span>
</div>

<Progress value={percentDone} className="h-2 mb-4" />

<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>
</Card>
);
};
49 changes: 48 additions & 1 deletion app/client/hooks/use-server-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ type ServerEventType =
| "volume:unmounted"
| "volume:updated"
| "mirror:started"
| "mirror:completed";
| "mirror:completed"
| "restore:started"
| "restore:progress"
| "restore:completed";

export interface BackupEvent {
scheduleId: number;
Expand Down Expand Up @@ -45,6 +48,24 @@ export interface MirrorEvent {
error?: string;
}

export interface RestoreEvent {
repositoryId: string;
snapshotId: string;
status?: "success" | "error";
error?: string;
}

export interface RestoreProgressEvent {
repositoryId: string;
snapshotId: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
}

type EventHandler = (data: unknown) => void;

/**
Expand Down Expand Up @@ -156,6 +177,32 @@ export function useServerEvents() {
});
});

eventSource.addEventListener("restore:started", (e) => {
const data = JSON.parse(e.data) as RestoreEvent;
console.log("[SSE] Restore started:", data);

handlersRef.current.get("restore:started")?.forEach((handler) => {
handler(data);
});
});

eventSource.addEventListener("restore:progress", (e) => {
const data = JSON.parse(e.data) as RestoreProgressEvent;

handlersRef.current.get("restore:progress")?.forEach((handler) => {
handler(data);
});
});

eventSource.addEventListener("restore:completed", (e) => {
const data = JSON.parse(e.data) as RestoreEvent;
console.log("[SSE] Restore completed:", data);

handlersRef.current.get("restore:completed")?.forEach((handler) => {
handler(data);
});
});

eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", error);
};
Expand Down
32 changes: 21 additions & 11 deletions app/client/modules/backups/routes/restore-snapshot.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { redirect } from "react-router";
import { Await, redirect } from "react-router";
import { getBackupSchedule, getRepository, getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
import { Suspense } from "react";

export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
Expand All @@ -27,31 +28,40 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
if (!schedule.data) return redirect("/backups");

const repositoryId = schedule.data.repository.id;
const snapshot = await getSnapshotDetails({
const snapshot = getSnapshotDetails({
path: { id: repositoryId, snapshotId: params.snapshotId },
});
if (!snapshot.data) return redirect(`/backups/${params.id}`);

const repository = await getRepository({ path: { id: repositoryId } });
if (!repository.data) return redirect(`/backups/${params.id}`);

return {
snapshot: snapshot.data,
snapshot: snapshot,
repository: repository.data,
snapshotId: params.snapshotId,
backupId: params.id,
};
};

export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
const { snapshot, repository, snapshotId, backupId } = loaderData;
const { repository, snapshotId, backupId } = loaderData;

return (
<RestoreForm
snapshot={snapshot}
repository={repository}
snapshotId={snapshotId}
returnPath={`/backups/${backupId}`}
/>
<Suspense fallback={<p>Loading snapshot details...</p>}>
<Await resolve={loaderData.snapshot}>
{(value) => {
if (!value.data) return <div className="text-destructive">Snapshot data not found.</div>;

return (
<RestoreForm
snapshot={value.data}
repository={repository}
snapshotId={snapshotId}
returnPath={`/backups/${backupId}`}
/>
);
}}
</Await>
</Suspense>
);
}
17 changes: 17 additions & 0 deletions app/server/core/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ interface ServerEvents {
repositoryName: string;
status: "success" | "error" | "stopped" | "warning";
}) => void;
"restore:started": (data: { repositoryId: string; snapshotId: string }) => void;
"restore:progress": (data: {
repositoryId: string;
snapshotId: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
}) => void;
"restore:completed": (data: {
repositoryId: string;
snapshotId: string;
status: "success" | "error";
error?: string;
}) => void;
"mirror:started": (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => void;
"mirror:completed": (data: {
scheduleId: number;
Expand Down
41 changes: 41 additions & 0 deletions app/server/modules/events/events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,41 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
});
};

const onRestoreStarted = (data: { repositoryId: string; snapshotId: string }) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "restore:started",
});
};

const onRestoreProgress = (data: {
repositoryId: string;
snapshotId: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
}) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "restore:progress",
});
};

const onRestoreCompleted = (data: {
repositoryId: string;
snapshotId: string;
status: "success" | "error";
error?: string;
}) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "restore:completed",
});
};

serverEvents.on("backup:started", onBackupStarted);
serverEvents.on("backup:progress", onBackupProgress);
serverEvents.on("backup:completed", onBackupCompleted);
Expand All @@ -99,6 +134,9 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
serverEvents.on("volume:updated", onVolumeUpdated);
serverEvents.on("mirror:started", onMirrorStarted);
serverEvents.on("mirror:completed", onMirrorCompleted);
serverEvents.on("restore:started", onRestoreStarted);
serverEvents.on("restore:progress", onRestoreProgress);
serverEvents.on("restore:completed", onRestoreCompleted);

let keepAlive = true;

Expand All @@ -113,6 +151,9 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
serverEvents.off("volume:updated", onVolumeUpdated);
serverEvents.off("mirror:started", onMirrorStarted);
serverEvents.off("mirror:completed", onMirrorCompleted);
serverEvents.off("restore:started", onRestoreStarted);
serverEvents.off("restore:progress", onRestoreProgress);
serverEvents.off("restore:completed", onRestoreCompleted);
});

while (keepAlive) {
Expand Down
2 changes: 2 additions & 0 deletions app/server/modules/repositories/repositories.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export const repositoriesController = new Hono()
summary: snapshot.summary,
};

c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");

return c.json<GetSnapshotDetailsDto>(response, 200);
})
.get(
Expand Down
24 changes: 23 additions & 1 deletion app/server/modules/repositories/repositories.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
import { repoMutex } from "../../core/repository-mutex";
import { serverEvents } from "../../core/events";
import {
repositoryConfigSchema,
type CompressionMode,
Expand Down Expand Up @@ -224,14 +225,35 @@ const restoreSnapshot = async (

const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
try {
const result = await restic.restore(repository.config, snapshotId, target, options);
serverEvents.emit("restore:started", { repositoryId: repository.id, snapshotId });

const result = await restic.restore(repository.config, snapshotId, target, {
...options,
onProgress: (progress) => {
serverEvents.emit("restore:progress", {
repositoryId: repository.id,
snapshotId,
...progress,
});
},
});

serverEvents.emit("restore:completed", { repositoryId: repository.id, snapshotId, status: "success" });

return {
success: true,
message: "Snapshot restored successfully",
filesRestored: result.files_restored,
filesSkipped: result.files_skipped,
};
} catch (error) {
serverEvents.emit("restore:completed", {
repositoryId: repository.id,
snapshotId,
status: "error",
error: toMessage(error),
});
throw error;
} finally {
releaseLock();
}
Expand Down
Loading