feat: manual backup option (#713)

Closes #710
This commit is contained in:
Nico 2026-03-26 19:35:18 +01:00 committed by GitHub
parent 6354705626
commit 866a3c63e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 143 additions and 16 deletions

View file

@ -39,7 +39,7 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
<span className="text-muted-foreground shrink-0">Schedule</span> <span className="text-muted-foreground shrink-0">Schedule</span>
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" /> <div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
<code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0"> <code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0">
{schedule.cronExpression} {schedule.cronExpression || "Manual only"}
</code> </code>
</div> </div>
<div className="flex items-center text-sm gap-2"> <div className="flex items-center text-sm gap-2">

View file

@ -27,6 +27,7 @@ export const FrequencySection = ({ form, frequency }: FrequencySectionProps) =>
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="manual">Manual only</SelectItem>
<SelectItem value="hourly">Hourly</SelectItem> <SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem> <SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem> <SelectItem value="weekly">Weekly</SelectItem>
@ -51,7 +52,7 @@ export const FrequencySection = ({ form, frequency }: FrequencySectionProps) =>
/> />
)} )}
{frequency !== "hourly" && frequency !== "cron" && ( {frequency !== "hourly" && frequency !== "cron" && frequency !== "manual" && (
<FormField <FormField
control={form.control} control={form.control}
name="dailyTime" name="dailyTime"

View file

@ -1,12 +1,13 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { cn } from "~/client/lib/utils";
import type { Volume } from "~/client/lib/types"; import type { Volume } from "~/client/lib/types";
import type { InternalFormValues } from "./types"; import type { InternalFormValues } from "./types";
type SummarySectionProps = { type SummarySectionProps = {
volume: Volume; volume: Volume;
frequency: string; frequency: string | undefined;
formValues: InternalFormValues; formValues: InternalFormValues;
}; };
@ -30,7 +31,13 @@ export const SummarySection = ({ volume, frequency, formValues }: SummarySection
</div> </div>
<div> <div>
<p className="text-xs uppercase text-muted-foreground">Schedule</p> <p className="text-xs uppercase text-muted-foreground">Schedule</p>
<p className="font-medium">{frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : "-"}</p> <p className="font-medium">
<span className={cn({ hidden: frequency !== "manual" })}>Manual only</span>
<span className={cn({ hidden: !frequency || frequency === "manual" })}>
{frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : null}
</span>
<span className={cn({ hidden: Boolean(frequency) })}>-</span>
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase text-muted-foreground">Repository</p> <p className="text-xs uppercase text-muted-foreground">Repository</p>

View file

@ -15,7 +15,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
return undefined; return undefined;
} }
const cronValues = cronToFormValues(schedule.cronExpression ?? "0 * * * *"); const cronValues = cronToFormValues(schedule.cronExpression ?? "");
return { return {
name: schedule.name, name: schedule.name,

View file

@ -56,7 +56,7 @@ export const ScheduleSummary = (props: Props) => {
}); });
const summary = useMemo(() => { const summary = useMemo(() => {
const scheduleLabel = schedule ? schedule.cronExpression : "-"; const scheduleLabel = schedule ? schedule.cronExpression || "Manual only" : "-";
const retentionParts: string[] = []; const retentionParts: string[] = [];
if (schedule?.retentionPolicy) { if (schedule?.retentionPolicy) {
@ -120,7 +120,11 @@ export const ScheduleSummary = (props: Props) => {
</Link> </Link>
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2 justify-between @medium:justify-start"> <div
className={cn("flex items-center gap-2 justify-between @medium:justify-start", {
hidden: !schedule.cronExpression,
})}
>
<OnOff <OnOff
isOn={schedule.enabled} isOn={schedule.enabled}
toggle={handleToggleEnabled} toggle={handleToggleEnabled}

View file

@ -78,7 +78,7 @@ const matchers: Matcher[] = [
export const cronToFormValues = (cronExpression: string): CronFormValues => { export const cronToFormValues = (cronExpression: string): CronFormValues => {
if (!cronExpression) { if (!cronExpression) {
return { frequency: "hourly" }; return { frequency: "manual" };
} }
const normalized = cronExpression.trim().replace(/\s+/g, " "); const normalized = cronExpression.trim().replace(/\s+/g, " ");

View file

@ -182,7 +182,7 @@ export function ScheduleDetailsPage(props: Props) {
body: { body: {
name: formValues.name, name: formValues.name,
repositoryId: formValues.repositoryId, repositoryId: formValues.repositoryId,
enabled: schedule.enabled, enabled: formValues.frequency === "manual" ? false : schedule.enabled,
cronExpression, cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePaths: formValues.includePaths, includePaths: formValues.includePaths,

View file

@ -66,7 +66,7 @@ export function CreateBackupPage() {
name: formValues.name, name: formValues.name,
volumeId: selectedVolumeShortId, volumeId: selectedVolumeShortId,
repositoryId: formValues.repositoryId, repositoryId: formValues.repositoryId,
enabled: true, enabled: formValues.frequency !== "manual",
cronExpression, cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePaths: formValues.includePaths, includePaths: formValues.includePaths,

View file

@ -112,6 +112,30 @@ describe("execute backup", () => {
expect(resticBackupMock).toHaveBeenCalled(); 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 () => { test("should skip the backup if the previous one is still running", async () => {
// arrange // arrange
const { resticBackupMock } = setup(); 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", () => { describe("listSchedules", () => {
test("should ignore schedules with missing relations", async () => { test("should ignore schedules with missing relations", async () => {
setup(); setup();

View file

@ -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, { await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, {
lastBackupAt: Date.now(), lastBackupAt: Date.now(),
lastBackupStatus: finalStatus, lastBackupStatus: finalStatus,
@ -272,7 +272,7 @@ const executeBackup = async (scheduleId: number, manual = false): Promise<void>
cache.del(cacheKeys.backup.progress(scheduleId)); cache.del(cacheKeys.backup.progress(scheduleId));
emitBackupStarted(ctx, 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, { await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, {
lastBackupStatus: "in_progress", lastBackupStatus: "in_progress",

View file

@ -41,7 +41,7 @@ export const scheduleQueries = {
lastBackupStatus?: BackupStatusType; lastBackupStatus?: BackupStatusType;
lastBackupAt?: number; lastBackupAt?: number;
lastBackupError?: string | null; lastBackupError?: string | null;
nextBackupAt?: number; nextBackupAt?: number | null;
}, },
) => { ) => {
return db return db

View file

@ -92,9 +92,12 @@ const getScheduleByIdOrShortId = async (idOrShortId: string | number) => {
const createSchedule = async (data: CreateBackupScheduleBody) => { const createSchedule = async (data: CreateBackupScheduleBody) => {
const organizationId = getOrganizationId(); const organizationId = getOrganizationId();
if (!isValidCron(data.cronExpression)) { if (data.cronExpression && !isValidCron(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression"); 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({ const existingName = await db.query.backupSchedulesTable.findFirst({
where: { where: {
@ -134,7 +137,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
if (paramError) throw new BadRequestError(paramError); if (paramError) throw new BadRequestError(paramError);
} }
const nextBackupAt = calculateNextRun(data.cronExpression); const nextBackupAt = data.cronExpression ? calculateNextRun(data.cronExpression) : null;
const [newSchedule] = await db const [newSchedule] = await db
.insert(backupSchedulesTable) .insert(backupSchedulesTable)
@ -182,6 +185,9 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
if (data.cronExpression && !isValidCron(data.cronExpression)) { if (data.cronExpression && !isValidCron(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression"); 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) { if (data.customResticParams && data.customResticParams.length > 0) {
const paramError = validateCustomResticParams(data.customResticParams); const paramError = validateCustomResticParams(data.customResticParams);
@ -211,7 +217,8 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
} }
const cronExpression = data.cronExpression ?? schedule.cronExpression; 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 const [updated] = await db
.update(backupSchedulesTable) .update(backupSchedulesTable)

View file

@ -7,6 +7,10 @@ export const getCronExpression = (
monthlyDays?: string[], monthlyDays?: string[],
cronExpression?: string, cronExpression?: string,
): string => { ): string => {
if (frequency === "manual") {
return "";
}
if (frequency === "cron" && cronExpression) { if (frequency === "cron" && cronExpression) {
return cronExpression; return cronExpression;
} }