feat: cache backup progress (#571)

Closes #412
This commit is contained in:
Nico 2026-02-25 18:20:08 +01:00 committed by GitHub
parent 00e7118771
commit cb92d36c95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 134 additions and 6 deletions

View file

@ -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<GetBackupProgressData>) =>
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<GetBackupProgressData>) =>
queryOptions<
GetBackupProgressResponse,
DefaultError,
GetBackupProgressResponse,
ReturnType<typeof getBackupProgressQueryKey>
>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getBackupProgress({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getBackupProgressQueryKey(options),
});
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) =>
createQueryKey("listNotificationDestinations", options);

View file

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

View file

@ -37,6 +37,8 @@ import type {
DownloadResticPasswordResponses,
DumpSnapshotData,
DumpSnapshotResponses,
GetBackupProgressData,
GetBackupProgressResponses,
GetBackupScheduleData,
GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponses,
@ -704,6 +706,17 @@ export const reorderBackupSchedules = <ThrowOnError extends boolean = false>(
},
});
/**
* Get the last known progress for a currently running backup. Returns null if no progress has been reported yet.
*/
export const getBackupProgress = <ThrowOnError extends boolean = false>(
options: Options<GetBackupProgressData, ThrowOnError>,
) =>
(options.client ?? client).get<GetBackupProgressResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/{shortId}/progress",
...options,
});
/**
* List all notification destinations
*/

View file

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

View file

@ -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<BackupProgressEvent | null>(null);
const [progress, setProgress] = useState<GetBackupProgressResponse>(initialProgress ?? null);
useEffect(() => {
const abortController = new AbortController();

View file

@ -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) => {
</CardContent>
</Card>
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleShortId={schedule.shortId} />}
{schedule.lastBackupStatus === "in_progress" && (
<BackupProgressCard scheduleShortId={schedule.shortId} initialProgress={initialProgress} />
)}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>

View file

@ -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<ReorderBackupSchedulesDto>({ 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<GetBackupProgressDto>(progress ?? null, 200);
});

View file

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

View file

@ -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<number, AbortController>();
const progressCache = new Map<number, BackupProgressEventDto>();
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<void>
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,
};