From 866a3c63e87a64a43df2cb8c9d0bd4982e91a4e0 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:35:18 +0100 Subject: [PATCH] feat: manual backup option (#713) Closes #710 --- .../backups/components/backup-card.tsx | 2 +- .../frequency-section.tsx | 3 +- .../create-schedule-form/summary-section.tsx | 11 +- .../components/create-schedule-form/utils.ts | 2 +- .../backups/components/schedule-summary.tsx | 8 +- app/client/modules/backups/lib/cron-utils.ts | 2 +- .../modules/backups/routes/backup-details.tsx | 2 +- .../modules/backups/routes/create-backup.tsx | 2 +- .../backups/__tests__/backups.service.test.ts | 104 ++++++++++++++++++ .../modules/backups/backups.execution.ts | 4 +- app/server/modules/backups/backups.queries.ts | 2 +- app/server/modules/backups/backups.service.ts | 13 ++- app/utils/utils.ts | 4 + 13 files changed, 143 insertions(+), 16 deletions(-) diff --git a/app/client/modules/backups/components/backup-card.tsx b/app/client/modules/backups/components/backup-card.tsx index 49b7db63..c99386f5 100644 --- a/app/client/modules/backups/components/backup-card.tsx +++ b/app/client/modules/backups/components/backup-card.tsx @@ -39,7 +39,7 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => { Schedule
- {schedule.cronExpression} + {schedule.cronExpression || "Manual only"}
diff --git a/app/client/modules/backups/components/create-schedule-form/frequency-section.tsx b/app/client/modules/backups/components/create-schedule-form/frequency-section.tsx index 4d1441fa..47d1654f 100644 --- a/app/client/modules/backups/components/create-schedule-form/frequency-section.tsx +++ b/app/client/modules/backups/components/create-schedule-form/frequency-section.tsx @@ -27,6 +27,7 @@ export const FrequencySection = ({ form, frequency }: FrequencySectionProps) => + Manual only Hourly Daily Weekly @@ -51,7 +52,7 @@ export const FrequencySection = ({ form, frequency }: FrequencySectionProps) => /> )} - {frequency !== "hourly" && frequency !== "cron" && ( + {frequency !== "hourly" && frequency !== "cron" && frequency !== "manual" && (

Schedule

-

{frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : "-"}

+

+ Manual only + + {frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : null} + + - +

Repository

diff --git a/app/client/modules/backups/components/create-schedule-form/utils.ts b/app/client/modules/backups/components/create-schedule-form/utils.ts index 6584ea25..5b0447fa 100644 --- a/app/client/modules/backups/components/create-schedule-form/utils.ts +++ b/app/client/modules/backups/components/create-schedule-form/utils.ts @@ -15,7 +15,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF return undefined; } - const cronValues = cronToFormValues(schedule.cronExpression ?? "0 * * * *"); + const cronValues = cronToFormValues(schedule.cronExpression ?? ""); return { name: schedule.name, diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index dec510d5..30c2a12b 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -56,7 +56,7 @@ export const ScheduleSummary = (props: Props) => { }); const summary = useMemo(() => { - const scheduleLabel = schedule ? schedule.cronExpression : "-"; + const scheduleLabel = schedule ? schedule.cronExpression || "Manual only" : "-"; const retentionParts: string[] = []; if (schedule?.retentionPolicy) { @@ -120,7 +120,11 @@ export const ScheduleSummary = (props: Props) => {
-
+
{ if (!cronExpression) { - return { frequency: "hourly" }; + return { frequency: "manual" }; } const normalized = cronExpression.trim().replace(/\s+/g, " "); diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index a581a7b3..8604d838 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -182,7 +182,7 @@ export function ScheduleDetailsPage(props: Props) { body: { name: formValues.name, repositoryId: formValues.repositoryId, - enabled: schedule.enabled, + enabled: formValues.frequency === "manual" ? false : schedule.enabled, cronExpression, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, includePaths: formValues.includePaths, diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index 80a34e91..74deeb9c 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -66,7 +66,7 @@ export function CreateBackupPage() { name: formValues.name, volumeId: selectedVolumeShortId, repositoryId: formValues.repositoryId, - enabled: true, + enabled: formValues.frequency !== "manual", cronExpression, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, includePaths: formValues.includePaths, diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index 41c5a3af..eaa16a05 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -112,6 +112,30 @@ describe("execute backup", () => { expect(resticBackupMock).toHaveBeenCalled(); }); + test("should keep next backup time empty for manual-only schedules after a manual run", async () => { + // arrange + const { resticBackupMock } = setup(); + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + cronExpression: "", + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }), + ); + + // act + await backupsExecutionService.executeBackup(schedule.id, true); + + // assert + const updatedSchedule = await backupsService.getScheduleById(schedule.id); + expect(updatedSchedule.nextBackupAt).toBeNull(); + }); + test("should skip the backup if the previous one is still running", async () => { // arrange const { resticBackupMock } = setup(); @@ -254,6 +278,86 @@ describe("getScheduleByIdOrShortId", () => { }); }); +describe("manual only schedules", () => { + test("should create a manual-only schedule without a next backup time", async () => { + setup(); + const volume = await createTestVolume(); + const repository = await createTestRepository(); + + const schedule = await backupsService.createSchedule({ + name: "manual-only", + volumeId: volume.shortId, + repositoryId: repository.shortId, + enabled: false, + cronExpression: "", + }); + + expect(schedule.cronExpression).toBe(""); + expect(schedule.nextBackupAt).toBeNull(); + expect(schedule.enabled).toBe(false); + }); + + test("should reject enabled manual-only schedules on create", async () => { + setup(); + const volume = await createTestVolume(); + const repository = await createTestRepository(); + + await expect( + backupsService.createSchedule({ + name: "manual-only", + volumeId: volume.shortId, + repositoryId: repository.shortId, + enabled: true, + cronExpression: "", + }), + ).rejects.toThrow("Enabled schedules require a cron expression"); + }); + + test("should clear the next backup time when updating a schedule to manual-only", async () => { + setup(); + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: true, + cronExpression: "0 0 * * *", + nextBackupAt: faker.date.future().getTime(), + }); + + const updatedSchedule = await backupsService.updateSchedule(schedule.id, { + repositoryId: repository.shortId, + enabled: false, + cronExpression: "", + }); + + expect(updatedSchedule.cronExpression).toBe(""); + expect(updatedSchedule.nextBackupAt).toBeNull(); + expect(updatedSchedule.enabled).toBe(false); + }); + + test("should reject enabled manual-only schedules on update", async () => { + setup(); + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + cronExpression: "", + nextBackupAt: null, + }); + + await expect( + backupsService.updateSchedule(schedule.id, { + repositoryId: repository.shortId, + enabled: true, + cronExpression: "", + }), + ).rejects.toThrow("Enabled schedules require a cron expression"); + }); +}); + describe("listSchedules", () => { test("should ignore schedules with missing relations", async () => { setup(); diff --git a/app/server/modules/backups/backups.execution.ts b/app/server/modules/backups/backups.execution.ts index 6de2b227..ba864f85 100644 --- a/app/server/modules/backups/backups.execution.ts +++ b/app/server/modules/backups/backups.execution.ts @@ -168,7 +168,7 @@ const finalizeSuccessfulBackup = async ( ); }); - const nextBackupAt = calculateNextRun(ctx.schedule.cronExpression); + const nextBackupAt = ctx.schedule.cronExpression ? calculateNextRun(ctx.schedule.cronExpression) : null; await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, { lastBackupAt: Date.now(), lastBackupStatus: finalStatus, @@ -272,7 +272,7 @@ const executeBackup = async (scheduleId: number, manual = false): Promise cache.del(cacheKeys.backup.progress(scheduleId)); emitBackupStarted(ctx, scheduleId); - const nextBackupAt = calculateNextRun(ctx.schedule.cronExpression); + const nextBackupAt = ctx.schedule.cronExpression ? calculateNextRun(ctx.schedule.cronExpression) : null; await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, { lastBackupStatus: "in_progress", diff --git a/app/server/modules/backups/backups.queries.ts b/app/server/modules/backups/backups.queries.ts index 5f64664b..49cde8fc 100644 --- a/app/server/modules/backups/backups.queries.ts +++ b/app/server/modules/backups/backups.queries.ts @@ -41,7 +41,7 @@ export const scheduleQueries = { lastBackupStatus?: BackupStatusType; lastBackupAt?: number; lastBackupError?: string | null; - nextBackupAt?: number; + nextBackupAt?: number | null; }, ) => { return db diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index ff2571eb..02609981 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -92,9 +92,12 @@ const getScheduleByIdOrShortId = async (idOrShortId: string | number) => { const createSchedule = async (data: CreateBackupScheduleBody) => { const organizationId = getOrganizationId(); - if (!isValidCron(data.cronExpression)) { + if (data.cronExpression && !isValidCron(data.cronExpression)) { throw new BadRequestError("Invalid cron expression"); } + if (data.enabled && !data.cronExpression) { + throw new BadRequestError("Enabled schedules require a cron expression"); + } const existingName = await db.query.backupSchedulesTable.findFirst({ where: { @@ -134,7 +137,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { if (paramError) throw new BadRequestError(paramError); } - const nextBackupAt = calculateNextRun(data.cronExpression); + const nextBackupAt = data.cronExpression ? calculateNextRun(data.cronExpression) : null; const [newSchedule] = await db .insert(backupSchedulesTable) @@ -182,6 +185,9 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update if (data.cronExpression && !isValidCron(data.cronExpression)) { throw new BadRequestError("Invalid cron expression"); } + if ((data.enabled ?? schedule.enabled) && data.cronExpression === "") { + throw new BadRequestError("Enabled schedules require a cron expression"); + } if (data.customResticParams && data.customResticParams.length > 0) { const paramError = validateCustomResticParams(data.customResticParams); @@ -211,7 +217,8 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update } const cronExpression = data.cronExpression ?? schedule.cronExpression; - const nextBackupAt = data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt; + const nextBackupAt = + data.cronExpression === "" ? null : data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt; const [updated] = await db .update(backupSchedulesTable) diff --git a/app/utils/utils.ts b/app/utils/utils.ts index 8c025c4c..a89fca68 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -7,6 +7,10 @@ export const getCronExpression = ( monthlyDays?: string[], cronExpression?: string, ): string => { + if (frequency === "manual") { + return ""; + } + if (frequency === "cron" && cronExpression) { return cronExpression; }