feat: backup progress

This commit is contained in:
Nicolas Meienberger
2025-11-09 12:11:00 +01:00
parent 4389029ba5
commit 5ff48f4d5d
13 changed files with 256 additions and 21 deletions

View File

@@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { ByteSize } from "~/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
import { formatDuration } from "~/utils/utils";
type Snapshot = ListSnapshotsResponse[number];
@@ -62,9 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatSnapshotDuration(snapshot.duration / 1000)}
</span>
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "~/lib/utils";
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -5,19 +5,33 @@ type ServerEventType =
| "connected"
| "heartbeat"
| "backup:started"
| "backup:progress"
| "backup:completed"
| "volume:mounted"
| "volume:unmounted"
| "volume:updated";
interface BackupEvent {
export interface BackupEvent {
scheduleId: number;
volumeName: string;
repositoryName: string;
status?: "success" | "error";
}
interface VolumeEvent {
export interface BackupProgressEvent {
scheduleId: number;
volumeName: string;
repositoryName: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
current_files: string[];
}
export interface VolumeEvent {
volumeName: string;
}
@@ -51,6 +65,14 @@ export function useServerEvents() {
});
});
eventSource.addEventListener("backup:progress", (e) => {
const data = JSON.parse(e.data) as BackupProgressEvent;
handlersRef.current.get("backup:progress")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("backup:completed", (e) => {
const data = JSON.parse(e.data) as BackupEvent;
console.log("[SSE] Backup completed:", data);

View File

@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { ByteSize, formatBytes } from "~/components/bytes-size";
import { Card } from "~/components/ui/card";
import { Progress } from "~/components/ui/progress";
import { type BackupProgressEvent, useServerEvents } from "~/hooks/use-server-events";
import { formatDuration } from "~/utils/utils";
type Props = {
scheduleId: number;
};
export const BackupProgressCard = ({ scheduleId }: Props) => {
const { addEventListener } = useServerEvents();
const [progress, setProgress] = useState<BackupProgressEvent | null>(null);
useEffect(() => {
const unsubscribe = addEventListener("backup:progress", (data) => {
const progressData = data as BackupProgressEvent;
if (progressData.scheduleId === scheduleId) {
setProgress(progressData);
}
});
const unsubscribeComplete = addEventListener("backup:completed", (data) => {
const completedData = data as { scheduleId: number };
if (completedData.scheduleId === scheduleId) {
setProgress(null);
}
});
return () => {
unsubscribe();
unsubscribeComplete();
};
}, [addEventListener, scheduleId]);
if (!progress) {
return (
<div className="rounded-lg border border-border bg-card 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="text-sm text-muted-foreground">Starting backup...</span>
</div>
</div>
);
}
const percentDone = Math.round(progress.percent_done * 100);
const currentFile = progress.current_files[0] || "";
const fileName = currentFile.split("/").pop() || currentFile;
const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<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">Backup in progress</span>
</div>
<span className="text-sm font-medium text-primary">{percentDone}%</span>
</div>
<Progress value={percentDone} className="h-2" />
<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>
{fileName && (
<div className="pt-2 border-t border-border">
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile}>
{fileName}
</p>
</div>
)}
</Card>
);
};

View File

@@ -13,6 +13,7 @@ import {
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import type { BackupSchedule } from "~/lib/types";
import { BackupProgressCard } from "./backup-progress-card";
type Props = {
schedule: BackupSchedule;
@@ -144,6 +145,8 @@ export const ScheduleSummary = (props: Props) => {
</CardContent>
</Card>
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { intervalToDuration } from "date-fns";
import { Database } from "lucide-react";
import { useState } from "react";
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
@@ -15,18 +14,6 @@ type Props = {
repository: Repository;
};
export const formatSnapshotDuration = (seconds: number) => {
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
const parts: string[] = [];
if (duration.days) parts.push(`${duration.days}d`);
if (duration.hours) parts.push(`${duration.hours}h`);
if (duration.minutes) parts.push(`${duration.minutes}m`);
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
return parts.join(" ");
};
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
const [searchQuery, setSearchQuery] = useState("");

View File

@@ -1,3 +1,5 @@
import { intervalToDuration } from "date-fns";
export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => {
if (frequency === "hourly") {
return "0 * * * *";
@@ -15,3 +17,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`;
};
export const formatDuration = (seconds: number) => {
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
const parts: string[] = [];
if (duration.days) parts.push(`${duration.days}d`);
if (duration.hours) parts.push(`${duration.hours}h`);
if (duration.minutes) parts.push(`${duration.minutes}m`);
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
return parts.join(" ");
};

View File

@@ -15,6 +15,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",

View File

@@ -6,6 +6,18 @@ import type { TypedEmitter } from "tiny-typed-emitter";
*/
interface ServerEvents {
"backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void;
"backup:progress": (data: {
scheduleId: number;
volumeName: string;
repositoryName: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
current_files: string[];
}) => void;
"backup:completed": (data: {
scheduleId: number;
volumeName: string;

View File

@@ -224,7 +224,17 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.include = schedule.includePatterns;
}
await restic.backup(repository.config, volumePath, backupOptions);
await restic.backup(repository.config, volumePath, {
...backupOptions,
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
...progress,
});
},
});
if (schedule.retentionPolicy) {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });

View File

@@ -19,6 +19,24 @@ export const eventsController = new Hono().get("/", (c) => {
});
};
const onBackupProgress = (data: {
scheduleId: number;
volumeName: string;
repositoryName: string;
secondsElapsed: number;
percentDone: number;
totalFiles: number;
filesDone: number;
totalBytes: number;
bytesDone: number;
currentFiles: string[];
}) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "backup:progress",
});
};
const onBackupCompleted = (data: {
scheduleId: number;
volumeName: string;
@@ -53,6 +71,7 @@ export const eventsController = new Hono().get("/", (c) => {
};
serverEvents.on("backup:started", onBackupStarted);
serverEvents.on("backup:progress", onBackupProgress);
serverEvents.on("backup:completed", onBackupCompleted);
serverEvents.on("volume:mounted", onVolumeMounted);
serverEvents.on("volume:unmounted", onVolumeUnmounted);
@@ -64,6 +83,7 @@ export const eventsController = new Hono().get("/", (c) => {
logger.info("Client disconnected from SSE endpoint");
keepAlive = false;
serverEvents.off("backup:started", onBackupStarted);
serverEvents.off("backup:progress", onBackupProgress);
serverEvents.off("backup:completed", onBackupCompleted);
serverEvents.off("volume:mounted", onVolumeMounted);
serverEvents.off("volume:unmounted", onVolumeUnmounted);

View File

@@ -113,10 +113,29 @@ const init = async (config: RepositoryConfig) => {
return { success: true, error: null };
};
const backupProgressSchema = type({
message_type: "'status'",
seconds_elapsed: "number",
percent_done: "number",
total_files: "number",
files_done: "number",
total_bytes: "number",
bytes_done: "number",
current_files: "string[]",
});
export type BackupProgress = typeof backupProgressSchema.infer;
const backup = async (
config: RepositoryConfig,
source: string,
options?: { exclude?: string[]; include?: string[]; tags?: string[]; signal?: AbortSignal },
options?: {
exclude?: string[];
include?: string[];
tags?: string[];
signal?: AbortSignal;
onProgress?: (progress: BackupProgress) => void;
},
) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
@@ -154,6 +173,20 @@ const backup = async (
logger.info(data.trim());
}, 5000);
const streamProgress = throttle((data: string) => {
if (options?.onProgress) {
try {
const jsonData = JSON.parse(data);
const progress = backupProgressSchema(jsonData);
if (!(progress instanceof type.errors)) {
options.onProgress(progress);
}
} catch (_) {
// Ignore JSON parse errors for non-JSON lines
}
}
}, 1000);
let stdout = "";
await safeSpawn({
@@ -164,6 +197,10 @@ const backup = async (
onStdout: (data) => {
stdout = data;
logData(data);
if (options?.onProgress) {
streamProgress(data);
}
},
onStderr: (error) => {
logger.error(error.trim());

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
@@ -377,6 +378,8 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
@@ -1485,6 +1488,10 @@
"@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
@@ -1663,6 +1670,8 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],