From 6aebebdb0d4e65abdf4b1f9df1db184b0fa41f3f Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 2 Jan 2026 14:13:45 +0100 Subject: [PATCH] feat: restore progress --- app/client/components/restore-form.tsx | 3 + app/client/components/restore-progress.tsx | 90 +++++++++++++++++++ app/client/hooks/use-server-events.ts | 49 +++++++++- .../backups/routes/restore-snapshot.tsx | 32 ++++--- app/server/core/events.ts | 17 ++++ .../modules/events/events.controller.ts | 41 +++++++++ .../repositories/repositories.controller.ts | 2 + .../repositories/repositories.service.ts | 24 ++++- app/server/utils/restic.ts | 51 +++++++++-- 9 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 app/client/components/restore-progress.tsx 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()} +

+
+
+

Data

+

+ / +

+
+
+

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}`);