diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx
index 55a6d0c..77882dd 100644
--- a/app/client/components/restore-form.tsx
+++ b/app/client/components/restore-form.tsx
@@ -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";
@@ -173,6 +174,8 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
+ {isRestoring &&
}
+
Restore Location
diff --git a/app/client/components/restore-progress.tsx b/app/client/components/restore-progress.tsx
new file mode 100644
index 0000000..2811107
--- /dev/null
+++ b/app/client/components/restore-progress.tsx
@@ -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(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 (
+
+
+
+
Restore in progress
+
+
+ );
+ }
+
+ const percentDone = Math.round(progress.percent_done * 100);
+ const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);
+
+ return (
+
+
+
+
+
Restore in progress
+
+
{percentDone}%
+
+
+
+
+
+
+
Files
+
+ {progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
+
+
+
+
+
Elapsed
+
{formatDuration(progress.seconds_elapsed)}
+
+
+
Speed
+
+ {progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."}
+
+
+
+
+ );
+};
diff --git a/app/client/hooks/use-server-events.ts b/app/client/hooks/use-server-events.ts
index d7c0377..cdb6ef3 100644
--- a/app/client/hooks/use-server-events.ts
+++ b/app/client/hooks/use-server-events.ts
@@ -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;
@@ -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;
/**
@@ -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);
};
diff --git a/app/client/modules/backups/routes/restore-snapshot.tsx b/app/client/modules/backups/routes/restore-snapshot.tsx
index 5b0c2c9..bd98c6b 100644
--- a/app/client/modules/backups/routes/restore-snapshot.tsx
+++ b/app/client/modules/backups/routes/restore-snapshot.tsx
@@ -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) => [
@@ -27,16 +28,15 @@ 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,
@@ -44,14 +44,24 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
};
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
- const { snapshot, repository, snapshotId, backupId } = loaderData;
+ const { repository, snapshotId, backupId } = loaderData;
return (
-
+ Loading snapshot details...}>
+
+ {(value) => {
+ if (!value.data) return Snapshot data not found.
;
+
+ return (
+
+ );
+ }}
+
+
);
}
diff --git a/app/server/core/events.ts b/app/server/core/events.ts
index 2e7f43d..99233cb 100644
--- a/app/server/core/events.ts
+++ b/app/server/core/events.ts
@@ -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;
diff --git a/app/server/modules/events/events.controller.ts b/app/server/modules/events/events.controller.ts
index 4661e13..81d3a7b 100644
--- a/app/server/modules/events/events.controller.ts
+++ b/app/server/modules/events/events.controller.ts
@@ -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);
@@ -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;
@@ -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) {
diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts
index 142f6ee..3dce08f 100644
--- a/app/server/modules/repositories/repositories.controller.ts
+++ b/app/server/modules/repositories/repositories.controller.ts
@@ -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(response, 200);
})
.get(
diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts
index 23f16a0..ea8cb78 100644
--- a/app/server/modules/repositories/repositories.service.ts
+++ b/app/server/modules/repositories/repositories.service.ts
@@ -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,
@@ -224,7 +225,20 @@ 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,
@@ -232,6 +246,14 @@ const restoreSnapshot = async (
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();
}
diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts
index 8f696f3..f282263 100644
--- a/app/server/utils/restic.ts
+++ b/app/server/utils/restic.ts
@@ -399,6 +399,18 @@ const restoreOutputSchema = type({
bytes_skipped: "number",
});
+const restoreProgressSchema = type({
+ message_type: "'status'",
+ seconds_elapsed: "number",
+ percent_done: "number",
+ total_files: "number",
+ files_done: "number",
+ total_bytes: "number",
+ bytes_done: "number",
+});
+
+export type RestoreProgress = typeof restoreProgressSchema.infer;
+
const restore = async (
config: RepositoryConfig,
snapshotId: string,
@@ -409,6 +421,8 @@ const restore = async (
excludeXattr?: string[];
delete?: boolean;
overwrite?: OverwriteMode;
+ onProgress?: (progress: RestoreProgress) => void;
+ signal?: AbortSignal;
},
) => {
const repoUrl = buildRepoUrl(config);
@@ -444,18 +458,45 @@ const restore = async (
addCommonArgs(args, env);
- logger.debug(`Executing: restic ${args.join(" ")}`);
- const res = await safeSpawn({ command: "restic", args, env });
+ const streamProgress = throttle((data: string) => {
+ if (options?.onProgress) {
+ try {
+ const jsonData = JSON.parse(data);
+ const progress = restoreProgressSchema(jsonData);
+ if (!(progress instanceof type.errors)) {
+ options.onProgress(progress);
+ }
+ } catch (_) {
+ // Ignore JSON parse errors for non-JSON lines
+ }
+ }
+ }, 1000);
- await cleanupTemporaryKeys(config, env);
+ let stdout = "";
+
+ logger.debug(`Executing: restic ${args.join(" ")}`);
+ const res = await safeSpawn({
+ command: "restic",
+ args,
+ env,
+ signal: options?.signal,
+ onStdout: (data) => {
+ stdout = data;
+ if (options?.onProgress) {
+ streamProgress(data);
+ }
+ },
+ finally: async () => {
+ await cleanupTemporaryKeys(config, env);
+ },
+ });
if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr);
}
- const outputLines = res.stdout.trim().split("\n");
- const lastLine = outputLines[outputLines.length - 1];
+ const lastLine = (stdout || res.stdout).trim().split("\n").pop();
if (!lastLine) {
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);