parent
00e7118771
commit
cb92d36c95
9 changed files with 134 additions and 6 deletions
|
|
@ -26,6 +26,7 @@ import {
|
||||||
devPanelExec,
|
devPanelExec,
|
||||||
downloadResticPassword,
|
downloadResticPassword,
|
||||||
dumpSnapshot,
|
dumpSnapshot,
|
||||||
|
getBackupProgress,
|
||||||
getBackupSchedule,
|
getBackupSchedule,
|
||||||
getBackupScheduleForVolume,
|
getBackupScheduleForVolume,
|
||||||
getDevPanel,
|
getDevPanel,
|
||||||
|
|
@ -104,6 +105,8 @@ import type {
|
||||||
DownloadResticPasswordResponse,
|
DownloadResticPasswordResponse,
|
||||||
DumpSnapshotData,
|
DumpSnapshotData,
|
||||||
DumpSnapshotResponse,
|
DumpSnapshotResponse,
|
||||||
|
GetBackupProgressData,
|
||||||
|
GetBackupProgressResponse,
|
||||||
GetBackupScheduleData,
|
GetBackupScheduleData,
|
||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
GetBackupScheduleForVolumeResponse,
|
GetBackupScheduleForVolumeResponse,
|
||||||
|
|
@ -1360,6 +1363,31 @@ export const reorderBackupSchedulesMutation = (
|
||||||
return mutationOptions;
|
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>) =>
|
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) =>
|
||||||
createQueryKey("listNotificationDestinations", options);
|
createQueryKey("listNotificationDestinations", options);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export {
|
||||||
devPanelExec,
|
devPanelExec,
|
||||||
downloadResticPassword,
|
downloadResticPassword,
|
||||||
dumpSnapshot,
|
dumpSnapshot,
|
||||||
|
getBackupProgress,
|
||||||
getBackupSchedule,
|
getBackupSchedule,
|
||||||
getBackupScheduleForVolume,
|
getBackupScheduleForVolume,
|
||||||
getDevPanel,
|
getDevPanel,
|
||||||
|
|
@ -114,6 +115,9 @@ export type {
|
||||||
DumpSnapshotData,
|
DumpSnapshotData,
|
||||||
DumpSnapshotResponse,
|
DumpSnapshotResponse,
|
||||||
DumpSnapshotResponses,
|
DumpSnapshotResponses,
|
||||||
|
GetBackupProgressData,
|
||||||
|
GetBackupProgressResponse,
|
||||||
|
GetBackupProgressResponses,
|
||||||
GetBackupScheduleData,
|
GetBackupScheduleData,
|
||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
GetBackupScheduleForVolumeResponse,
|
GetBackupScheduleForVolumeResponse,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ import type {
|
||||||
DownloadResticPasswordResponses,
|
DownloadResticPasswordResponses,
|
||||||
DumpSnapshotData,
|
DumpSnapshotData,
|
||||||
DumpSnapshotResponses,
|
DumpSnapshotResponses,
|
||||||
|
GetBackupProgressData,
|
||||||
|
GetBackupProgressResponses,
|
||||||
GetBackupScheduleData,
|
GetBackupScheduleData,
|
||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
GetBackupScheduleForVolumeResponses,
|
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
|
* List all notification destinations
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -3978,6 +3978,35 @@ export type ReorderBackupSchedulesResponses = {
|
||||||
|
|
||||||
export type ReorderBackupSchedulesResponse = ReorderBackupSchedulesResponses[keyof 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 = {
|
export type ListNotificationDestinationsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,19 @@ import { useEffect, useState } from "react";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { Card } from "~/client/components/ui/card";
|
import { Card } from "~/client/components/ui/card";
|
||||||
import { Progress } from "~/client/components/ui/progress";
|
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 { formatDuration } from "~/utils/utils";
|
||||||
import { formatBytes } from "~/utils/format-bytes";
|
import { formatBytes } from "~/utils/format-bytes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
scheduleShortId: string;
|
scheduleShortId: string;
|
||||||
|
initialProgress: GetBackupProgressResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupProgressCard = ({ scheduleShortId }: Props) => {
|
export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) => {
|
||||||
const { addEventListener } = useServerEvents();
|
const { addEventListener } = useServerEvents();
|
||||||
const [progress, setProgress] = useState<BackupProgressEvent | null>(null);
|
const [progress, setProgress] = useState<GetBackupProgressResponse>(initialProgress ?? null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ import {
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import type { BackupSchedule } from "~/client/lib/types";
|
import type { BackupSchedule } from "~/client/lib/types";
|
||||||
import { BackupProgressCard } from "./backup-progress-card";
|
import { BackupProgressCard } from "./backup-progress-card";
|
||||||
import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { handleRepositoryError } from "~/client/lib/errors";
|
import { handleRepositoryError } from "~/client/lib/errors";
|
||||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
||||||
|
|
@ -37,6 +37,10 @@ export const ScheduleSummary = (props: Props) => {
|
||||||
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
||||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||||
|
|
||||||
|
const { data: initialProgress } = useSuspenseQuery({
|
||||||
|
...getBackupProgressOptions({ path: { shortId: schedule.shortId } }),
|
||||||
|
});
|
||||||
|
|
||||||
const runForget = useMutation({
|
const runForget = useMutation({
|
||||||
...runForgetMutation(),
|
...runForgetMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -215,7 +219,9 @@ export const ScheduleSummary = (props: Props) => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleShortId={schedule.shortId} />}
|
{schedule.lastBackupStatus === "in_progress" && (
|
||||||
|
<BackupProgressCard scheduleShortId={schedule.shortId} initialProgress={initialProgress} />
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
getMirrorCompatibilityDto,
|
getMirrorCompatibilityDto,
|
||||||
reorderBackupSchedulesDto,
|
reorderBackupSchedulesDto,
|
||||||
reorderBackupSchedulesBody,
|
reorderBackupSchedulesBody,
|
||||||
|
getBackupProgressDto,
|
||||||
type CreateBackupScheduleDto,
|
type CreateBackupScheduleDto,
|
||||||
type DeleteBackupScheduleDto,
|
type DeleteBackupScheduleDto,
|
||||||
type GetBackupScheduleDto,
|
type GetBackupScheduleDto,
|
||||||
|
|
@ -31,6 +32,7 @@ import {
|
||||||
type UpdateScheduleMirrorsDto,
|
type UpdateScheduleMirrorsDto,
|
||||||
type GetMirrorCompatibilityDto,
|
type GetMirrorCompatibilityDto,
|
||||||
type ReorderBackupSchedulesDto,
|
type ReorderBackupSchedulesDto,
|
||||||
|
type GetBackupProgressDto,
|
||||||
} from "./backups.dto";
|
} from "./backups.dto";
|
||||||
import { backupsService } from "./backups.service";
|
import { backupsService } from "./backups.service";
|
||||||
import {
|
import {
|
||||||
|
|
@ -161,4 +163,11 @@ export const backupScheduleController = new Hono()
|
||||||
await backupsService.reorderSchedules(body.scheduleShortIds.map(asShortId));
|
await backupsService.reorderSchedules(body.scheduleShortIds.map(asShortId));
|
||||||
|
|
||||||
return c.json<ReorderBackupSchedulesDto>({ success: true }, 200);
|
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 { describeRoute, resolver } from "hono-openapi";
|
||||||
import { volumeSchema } from "../volumes/volume.dto";
|
import { volumeSchema } from "../volumes/volume.dto";
|
||||||
import { repositorySchema } from "../repositories/repositories.dto";
|
import { repositorySchema } from "../repositories/repositories.dto";
|
||||||
|
import { backupProgressEventSchema } from "~/schemas/events-dto";
|
||||||
|
|
||||||
const retentionPolicySchema = type({
|
const retentionPolicySchema = type({
|
||||||
keepLast: "number?",
|
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 { scheduleQueries, mirrorQueries, repositoryQueries } from "./backups.queries";
|
||||||
import { calculateNextRun, createBackupOptions } from "./backup.helpers";
|
import { calculateNextRun, createBackupOptions } from "./backup.helpers";
|
||||||
import type { ResticBackupOutputDto } from "~/schemas/restic-dto";
|
import type { ResticBackupOutputDto } from "~/schemas/restic-dto";
|
||||||
|
import type { BackupProgressEventDto } from "~/schemas/events-dto";
|
||||||
|
|
||||||
const runningBackups = new Map<number, AbortController>();
|
const runningBackups = new Map<number, AbortController>();
|
||||||
|
const progressCache = new Map<number, BackupProgressEventDto>();
|
||||||
|
|
||||||
|
export const getBackupProgress = (scheduleId: number): BackupProgressEventDto | undefined =>
|
||||||
|
progressCache.get(scheduleId);
|
||||||
|
|
||||||
interface BackupContext {
|
interface BackupContext {
|
||||||
schedule: BackupSchedule;
|
schedule: BackupSchedule;
|
||||||
|
|
@ -117,6 +122,12 @@ const runBackupOperation = async (ctx: BackupContext, signal: AbortSignal) => {
|
||||||
compressionMode: ctx.repository.compressionMode ?? "auto",
|
compressionMode: ctx.repository.compressionMode ?? "auto",
|
||||||
organizationId: ctx.organizationId,
|
organizationId: ctx.organizationId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
|
progressCache.set(ctx.schedule.id, {
|
||||||
|
scheduleId: ctx.schedule.shortId,
|
||||||
|
volumeName: ctx.volume.name,
|
||||||
|
repositoryName: ctx.repository.name,
|
||||||
|
...progress,
|
||||||
|
});
|
||||||
serverEvents.emit("backup:progress", {
|
serverEvents.emit("backup:progress", {
|
||||||
organizationId: ctx.organizationId,
|
organizationId: ctx.organizationId,
|
||||||
scheduleId: ctx.schedule.shortId,
|
scheduleId: ctx.schedule.shortId,
|
||||||
|
|
@ -272,6 +283,7 @@ const executeBackup = async (scheduleId: number, manual = false): Promise<void>
|
||||||
await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx);
|
await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx);
|
||||||
} finally {
|
} finally {
|
||||||
runningBackups.delete(scheduleId);
|
runningBackups.delete(scheduleId);
|
||||||
|
progressCache.delete(scheduleId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -448,4 +460,5 @@ export const backupsExecutionService = {
|
||||||
stopBackup,
|
stopBackup,
|
||||||
runForget,
|
runForget,
|
||||||
copyToMirrors,
|
copyToMirrors,
|
||||||
|
getBackupProgress,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue