mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-02-07 03:51:13 -05:00
feat: restore progress
This commit is contained in:
@@ -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>
|
||||
|
||||
90
app/client/components/restore-progress.tsx
Normal file
90
app/client/components/restore-progress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user