parent
00e7118771
commit
cb92d36c95
9 changed files with 134 additions and 6 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue