parent
429b69ec92
commit
1b5ae2b3cc
5 changed files with 59 additions and 39 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
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 { useServerEvents } from "~/client/hooks/use-server-events";
|
import { getBackupProgressOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import type { GetBackupProgressResponse } from "~/client/api-client/types.gen";
|
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";
|
||||||
|
|
@ -13,34 +13,11 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) => {
|
export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) => {
|
||||||
const { addEventListener } = useServerEvents();
|
const { data: progress } = useQuery({
|
||||||
const [progress, setProgress] = useState<GetBackupProgressResponse>(initialProgress ?? null);
|
...getBackupProgressOptions({ path: { shortId: scheduleShortId } }),
|
||||||
|
initialData: initialProgress,
|
||||||
useEffect(() => {
|
refetchInterval: 1000,
|
||||||
const abortController = new AbortController();
|
});
|
||||||
|
|
||||||
addEventListener(
|
|
||||||
"backup:progress",
|
|
||||||
(progressData) => {
|
|
||||||
if (progressData.scheduleId === scheduleShortId) {
|
|
||||||
setProgress(progressData);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ signal: abortController.signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
addEventListener(
|
|
||||||
"backup:completed",
|
|
||||||
(completedData) => {
|
|
||||||
if (completedData.scheduleId === scheduleShortId) {
|
|
||||||
setProgress(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ signal: abortController.signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => abortController.abort();
|
|
||||||
}, [addEventListener, scheduleShortId]);
|
|
||||||
|
|
||||||
const percentDone = progress ? Math.round(progress.percent_done * 100) : 0;
|
const percentDone = progress ? Math.round(progress.percent_done * 100) : 0;
|
||||||
const currentFile = progress?.current_files?.[0] || "";
|
const currentFile = progress?.current_files?.[0] || "";
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
|
|
||||||
const { data: schedule } = useSuspenseQuery({
|
const { data: schedule } = useSuspenseQuery({
|
||||||
...getBackupScheduleOptions({ path: { shortId: scheduleId } }),
|
...getBackupScheduleOptions({ path: { shortId: scheduleId } }),
|
||||||
|
refetchInterval: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
||||||
import { createTestVolume } from "~/test/helpers/volume";
|
import { createTestVolume } from "~/test/helpers/volume";
|
||||||
import { createTestRepository } from "~/test/helpers/repository";
|
import { createTestRepository } from "~/test/helpers/repository";
|
||||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||||
|
import { cache, cacheKeys } from "~/server/utils/cache";
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -81,6 +82,47 @@ describe("backups security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
describe("input validation", () => {
|
||||||
|
test("should return cached progress for a running backup", async () => {
|
||||||
|
const { headers, organizationId } = await createTestSession();
|
||||||
|
const volume = await createTestVolume({ organizationId });
|
||||||
|
const repository = await createTestRepository({ organizationId });
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
organizationId,
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.set(cacheKeys.backup.progress(schedule.id), {
|
||||||
|
scheduleId: schedule.shortId,
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message_type: "status",
|
||||||
|
seconds_elapsed: 12,
|
||||||
|
seconds_remaining: 24,
|
||||||
|
percent_done: 0.5,
|
||||||
|
total_files: 100,
|
||||||
|
files_done: 50,
|
||||||
|
total_bytes: 1024,
|
||||||
|
bytes_done: 512,
|
||||||
|
current_files: ["/mnt/data/file.txt"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/api/v1/backups/${schedule.shortId}/progress`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
scheduleId: schedule.shortId,
|
||||||
|
percent_done: 0.5,
|
||||||
|
files_done: 50,
|
||||||
|
current_files: ["/mnt/data/file.txt"],
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.del(cacheKeys.backup.progress(schedule.id));
|
||||||
|
});
|
||||||
|
|
||||||
test("should return a schedule when queried by short id", async () => {
|
test("should return a schedule when queried by short id", async () => {
|
||||||
const { headers, organizationId } = await createTestSession();
|
const { headers, organizationId } = await createTestSession();
|
||||||
const volume = await createTestVolume({ organizationId });
|
const volume = await createTestVolume({ organizationId });
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,9 @@ import type { ResticBackupOutputDto } from "@zerobyte/core/restic";
|
||||||
import type { BackupProgressEventDto } from "~/schemas/events-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 =>
|
export const getBackupProgress = (scheduleId: number): BackupProgressEventDto | undefined =>
|
||||||
progressCache.get(scheduleId);
|
cache.get<BackupProgressEventDto>(cacheKeys.backup.progress(scheduleId));
|
||||||
|
|
||||||
interface BackupContext {
|
interface BackupContext {
|
||||||
schedule: BackupSchedule;
|
schedule: BackupSchedule;
|
||||||
|
|
@ -123,18 +122,16 @@ 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, {
|
const progressEvent = {
|
||||||
scheduleId: ctx.schedule.shortId,
|
scheduleId: ctx.schedule.shortId,
|
||||||
volumeName: ctx.volume.name,
|
volumeName: ctx.volume.name,
|
||||||
repositoryName: ctx.repository.name,
|
repositoryName: ctx.repository.name,
|
||||||
...progress,
|
...progress,
|
||||||
});
|
};
|
||||||
|
cache.set(cacheKeys.backup.progress(ctx.schedule.id), progressEvent, 60 * 60);
|
||||||
serverEvents.emit("backup:progress", {
|
serverEvents.emit("backup:progress", {
|
||||||
organizationId: ctx.organizationId,
|
organizationId: ctx.organizationId,
|
||||||
scheduleId: ctx.schedule.shortId,
|
...progressEvent,
|
||||||
volumeName: ctx.volume.name,
|
|
||||||
repositoryName: ctx.repository.name,
|
|
||||||
...progress,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -297,7 +294,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);
|
cache.del(cacheKeys.backup.progress(scheduleId));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,9 @@ export const createCache = (options: CacheOptions = {}) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cacheKeys = {
|
export const cacheKeys = {
|
||||||
|
backup: {
|
||||||
|
progress: (scheduleId: number) => `backup:${scheduleId}:progress`,
|
||||||
|
},
|
||||||
repository: {
|
repository: {
|
||||||
all: (repositoryId: string) => `repo:${repositoryId}:`,
|
all: (repositoryId: string) => `repo:${repositoryId}:`,
|
||||||
snapshots: (repositoryId: string, backupId = "all") => `repo:${repositoryId}:snapshots:${backupId}`,
|
snapshots: (repositoryId: string, backupId = "all") => `repo:${repositoryId}:snapshots:${backupId}`,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue