+
{
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;
}