diff --git a/apps/client/app/components/snapshots-table.tsx b/apps/client/app/components/snapshots-table.tsx index e33a199..922ddde 100644 --- a/apps/client/app/components/snapshots-table.tsx +++ b/apps/client/app/components/snapshots-table.tsx @@ -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) => {
- - {formatSnapshotDuration(snapshot.duration / 1000)} - + {formatDuration(snapshot.duration / 1000)}
diff --git a/apps/client/app/components/ui/progress.tsx b/apps/client/app/components/ui/progress.tsx new file mode 100644 index 0000000..90ec025 --- /dev/null +++ b/apps/client/app/components/ui/progress.tsx @@ -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) { + return ( + + + + ); +} + +export { Progress }; diff --git a/apps/client/app/hooks/use-server-events.ts b/apps/client/app/hooks/use-server-events.ts index 4b873aa..43c7205 100644 --- a/apps/client/app/hooks/use-server-events.ts +++ b/apps/client/app/hooks/use-server-events.ts @@ -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); diff --git a/apps/client/app/modules/backups/components/backup-progress-card.tsx b/apps/client/app/modules/backups/components/backup-progress-card.tsx new file mode 100644 index 0000000..97429e5 --- /dev/null +++ b/apps/client/app/modules/backups/components/backup-progress-card.tsx @@ -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(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 ( +
+
+
+ Starting backup... +
+
+ ); + } + + 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 ( + +
+
+
+ Backup 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..."} +

+
+
+ + {fileName && ( +
+

Current file

+

+ {fileName} +

+
+ )} + + ); +}; diff --git a/apps/client/app/modules/backups/components/schedule-summary.tsx b/apps/client/app/modules/backups/components/schedule-summary.tsx index edb8b2b..de19964 100644 --- a/apps/client/app/modules/backups/components/schedule-summary.tsx +++ b/apps/client/app/modules/backups/components/schedule-summary.tsx @@ -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) => { + {schedule.lastBackupStatus === "in_progress" && } + diff --git a/apps/client/app/modules/repositories/tabs/snapshots.tsx b/apps/client/app/modules/repositories/tabs/snapshots.tsx index 341d1b8..8bc21cf 100644 --- a/apps/client/app/modules/repositories/tabs/snapshots.tsx +++ b/apps/client/app/modules/repositories/tabs/snapshots.tsx @@ -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(""); diff --git a/apps/client/app/utils/utils.ts b/apps/client/app/utils/utils.ts index a3a5736..dc5d3a2 100644 --- a/apps/client/app/utils/utils.ts +++ b/apps/client/app/utils/utils.ts @@ -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(" "); +}; diff --git a/apps/client/package.json b/apps/client/package.json index 9abd821..d95e118 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/server/src/core/events.ts b/apps/server/src/core/events.ts index 2e5d838..8e55c0e 100644 --- a/apps/server/src/core/events.ts +++ b/apps/server/src/core/events.ts @@ -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; diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts index 4d26a52..6ed67e6 100644 --- a/apps/server/src/modules/backups/backups.service.ts +++ b/apps/server/src/modules/backups/backups.service.ts @@ -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() }); diff --git a/apps/server/src/modules/events/events.controller.ts b/apps/server/src/modules/events/events.controller.ts index 038aabe..06bdb9f 100644 --- a/apps/server/src/modules/events/events.controller.ts +++ b/apps/server/src/modules/events/events.controller.ts @@ -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); diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index c289d00..bccc325 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -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()); diff --git a/bun.lock b/bun.lock index 9eb481b..6575e51 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="],