From cb92d36c958f1a47bcdfb756118e41d981fd05c8 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:20:08 +0100 Subject: [PATCH] feat: cache backup progress (#571) Closes #412 --- .../api-client/@tanstack/react-query.gen.ts | 28 ++++++++++++++++++ app/client/api-client/index.ts | 4 +++ app/client/api-client/sdk.gen.ts | 13 +++++++++ app/client/api-client/types.gen.ts | 29 +++++++++++++++++++ .../components/backup-progress-card.tsx | 8 +++-- .../backups/components/schedule-summary.tsx | 12 ++++++-- .../modules/backups/backups.controller.ts | 9 ++++++ app/server/modules/backups/backups.dto.ts | 24 +++++++++++++++ .../modules/backups/backups.execution.ts | 13 +++++++++ 9 files changed, 134 insertions(+), 6 deletions(-) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 95287be8..919f4fa3 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -26,6 +26,7 @@ import { devPanelExec, downloadResticPassword, dumpSnapshot, + getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, @@ -104,6 +105,8 @@ import type { DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, + GetBackupProgressData, + GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, @@ -1360,6 +1363,31 @@ export const reorderBackupSchedulesMutation = ( return mutationOptions; }; +export const getBackupProgressQueryKey = (options: Options) => + createQueryKey("getBackupProgress", options); + +/** + * Get the last known progress for a currently running backup. Returns null if no progress has been reported yet. + */ +export const getBackupProgressOptions = (options: Options) => + queryOptions< + GetBackupProgressResponse, + DefaultError, + GetBackupProgressResponse, + ReturnType + >({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getBackupProgress({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getBackupProgressQueryKey(options), + }); + export const listNotificationDestinationsQueryKey = (options?: Options) => createQueryKey("listNotificationDestinations", options); diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index dadecaf4..18de4b20 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -17,6 +17,7 @@ export { devPanelExec, downloadResticPassword, dumpSnapshot, + getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, @@ -114,6 +115,9 @@ export type { DumpSnapshotData, DumpSnapshotResponse, DumpSnapshotResponses, + GetBackupProgressData, + GetBackupProgressResponse, + GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 14aa4fc2..247e80de 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -37,6 +37,8 @@ import type { DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, + GetBackupProgressData, + GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, @@ -704,6 +706,17 @@ export const reorderBackupSchedules = ( }, }); +/** + * Get the last known progress for a currently running backup. Returns null if no progress has been reported yet. + */ +export const getBackupProgress = ( + options: Options, +) => + (options.client ?? client).get({ + url: "/api/v1/backups/{shortId}/progress", + ...options, + }); + /** * List all notification destinations */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index eac7e763..1374a66f 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -3978,6 +3978,35 @@ export type ReorderBackupSchedulesResponses = { export type ReorderBackupSchedulesResponse = ReorderBackupSchedulesResponses[keyof ReorderBackupSchedulesResponses]; +export type GetBackupProgressData = { + body?: never; + path: { + shortId: string; + }; + query?: never; + url: "/api/v1/backups/{shortId}/progress"; +}; + +export type GetBackupProgressResponses = { + /** + * Current backup progress or null if not yet available + */ + 200: { + bytes_done: number; + current_files: Array; + files_done: number; + percent_done: number; + repositoryName: string; + scheduleId: string; + seconds_elapsed: number; + total_bytes: number; + total_files: number; + volumeName: string; + } | null; +}; + +export type GetBackupProgressResponse = GetBackupProgressResponses[keyof GetBackupProgressResponses]; + export type ListNotificationDestinationsData = { body?: never; path?: never; diff --git a/app/client/modules/backups/components/backup-progress-card.tsx b/app/client/modules/backups/components/backup-progress-card.tsx index b7df6747..247f8830 100644 --- a/app/client/modules/backups/components/backup-progress-card.tsx +++ b/app/client/modules/backups/components/backup-progress-card.tsx @@ -2,17 +2,19 @@ import { useEffect, useState } from "react"; import { ByteSize } from "~/client/components/bytes-size"; import { Card } from "~/client/components/ui/card"; import { Progress } from "~/client/components/ui/progress"; -import { type BackupProgressEvent, useServerEvents } from "~/client/hooks/use-server-events"; +import { useServerEvents } from "~/client/hooks/use-server-events"; +import type { GetBackupProgressResponse } from "~/client/api-client/types.gen"; import { formatDuration } from "~/utils/utils"; import { formatBytes } from "~/utils/format-bytes"; type Props = { scheduleShortId: string; + initialProgress: GetBackupProgressResponse; }; -export const BackupProgressCard = ({ scheduleShortId }: Props) => { +export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) => { const { addEventListener } = useServerEvents(); - const [progress, setProgress] = useState(null); + const [progress, setProgress] = useState(initialProgress ?? null); useEffect(() => { const abortController = new AbortController(); diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index 520b5c3c..f978d3c3 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -14,8 +14,8 @@ import { } from "~/client/components/ui/alert-dialog"; import type { BackupSchedule } from "~/client/lib/types"; import { BackupProgressCard } from "./backup-progress-card"; -import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen"; -import { useMutation } from "@tanstack/react-query"; +import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { handleRepositoryError } from "~/client/lib/errors"; import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime"; @@ -37,6 +37,10 @@ export const ScheduleSummary = (props: Props) => { const [showForgetConfirm, setShowForgetConfirm] = useState(false); const [showStopConfirm, setShowStopConfirm] = useState(false); + const { data: initialProgress } = useSuspenseQuery({ + ...getBackupProgressOptions({ path: { shortId: schedule.shortId } }), + }); + const runForget = useMutation({ ...runForgetMutation(), onSuccess: () => { @@ -215,7 +219,9 @@ export const ScheduleSummary = (props: Props) => { - {schedule.lastBackupStatus === "in_progress" && } + {schedule.lastBackupStatus === "in_progress" && ( + + )} diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index 43a531a9..5eb0a2b6 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -18,6 +18,7 @@ import { getMirrorCompatibilityDto, reorderBackupSchedulesDto, reorderBackupSchedulesBody, + getBackupProgressDto, type CreateBackupScheduleDto, type DeleteBackupScheduleDto, type GetBackupScheduleDto, @@ -31,6 +32,7 @@ import { type UpdateScheduleMirrorsDto, type GetMirrorCompatibilityDto, type ReorderBackupSchedulesDto, + type GetBackupProgressDto, } from "./backups.dto"; import { backupsService } from "./backups.service"; import { @@ -161,4 +163,11 @@ export const backupScheduleController = new Hono() await backupsService.reorderSchedules(body.scheduleShortIds.map(asShortId)); return c.json({ success: true }, 200); + }) + .get("/:shortId/progress", getBackupProgressDto, async (c) => { + const shortId = asShortId(c.req.param("shortId")); + const schedule = await backupsService.getScheduleByShortId(shortId); + const progress = backupsExecutionService.getBackupProgress(schedule.id); + + return c.json(progress ?? null, 200); }); diff --git a/app/server/modules/backups/backups.dto.ts b/app/server/modules/backups/backups.dto.ts index ae8a8456..4087acf1 100644 --- a/app/server/modules/backups/backups.dto.ts +++ b/app/server/modules/backups/backups.dto.ts @@ -2,6 +2,7 @@ import { type } from "arktype"; import { describeRoute, resolver } from "hono-openapi"; import { volumeSchema } from "../volumes/volume.dto"; import { repositorySchema } from "../repositories/repositories.dto"; +import { backupProgressEventSchema } from "~/schemas/events-dto"; const retentionPolicySchema = type({ keepLast: "number?", @@ -402,3 +403,26 @@ export const reorderBackupSchedulesDto = describeRoute({ }, }, }); + +/** + * Get current backup progress for a running backup + */ +const getBackupProgressResponse = backupProgressEventSchema.or("null"); +export type GetBackupProgressDto = typeof getBackupProgressResponse.infer; + +export const getBackupProgressDto = describeRoute({ + description: + "Get the last known progress for a currently running backup. Returns null if no progress has been reported yet.", + tags: ["Backup Schedules"], + operationId: "getBackupProgress", + responses: { + 200: { + description: "Current backup progress or null if not yet available", + content: { + "application/json": { + schema: resolver(getBackupProgressResponse), + }, + }, + }, + }, +}); diff --git a/app/server/modules/backups/backups.execution.ts b/app/server/modules/backups/backups.execution.ts index 6a0b044c..fbc88ebe 100644 --- a/app/server/modules/backups/backups.execution.ts +++ b/app/server/modules/backups/backups.execution.ts @@ -12,8 +12,13 @@ import { getOrganizationId } from "~/server/core/request-context"; import { scheduleQueries, mirrorQueries, repositoryQueries } from "./backups.queries"; import { calculateNextRun, createBackupOptions } from "./backup.helpers"; import type { ResticBackupOutputDto } from "~/schemas/restic-dto"; +import type { BackupProgressEventDto } from "~/schemas/events-dto"; const runningBackups = new Map(); +const progressCache = new Map(); + +export const getBackupProgress = (scheduleId: number): BackupProgressEventDto | undefined => + progressCache.get(scheduleId); interface BackupContext { schedule: BackupSchedule; @@ -117,6 +122,12 @@ const runBackupOperation = async (ctx: BackupContext, signal: AbortSignal) => { compressionMode: ctx.repository.compressionMode ?? "auto", organizationId: ctx.organizationId, onProgress: (progress) => { + progressCache.set(ctx.schedule.id, { + scheduleId: ctx.schedule.shortId, + volumeName: ctx.volume.name, + repositoryName: ctx.repository.name, + ...progress, + }); serverEvents.emit("backup:progress", { organizationId: ctx.organizationId, scheduleId: ctx.schedule.shortId, @@ -272,6 +283,7 @@ const executeBackup = async (scheduleId: number, manual = false): Promise await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx); } finally { runningBackups.delete(scheduleId); + progressCache.delete(scheduleId); } }; @@ -448,4 +460,5 @@ export const backupsExecutionService = { stopBackup, runForget, copyToMirrors, + getBackupProgress, };