feat: restore progress

This commit is contained in:
Nicolas Meienberger
2026-01-02 14:13:45 +01:00
parent eded452c83
commit 6aebebdb0d
9 changed files with 291 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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