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>
<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">
{schedule.cronExpression}
{schedule.cronExpression || "Manual only"}
</code>
</div>
<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" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual only</SelectItem>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</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
control={form.control}
name="dailyTime"

View file

@ -1,12 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
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 { InternalFormValues } from "./types";
type SummarySectionProps = {
volume: Volume;
frequency: string;
frequency: string | undefined;
formValues: InternalFormValues;
};
@ -30,7 +31,13 @@ export const SummarySection = ({ volume, frequency, formValues }: SummarySection
</div>
<div>
<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>
<p className="text-xs uppercase text-muted-foreground">Repository</p>

View file

@ -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,

View file

@ -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) => {
</Link>
</CardDescription>
</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
isOn={schedule.enabled}
toggle={handleToggleEnabled}

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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();

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, {
lastBackupAt: Date.now(),
lastBackupStatus: finalStatus,
@ -272,7 +272,7 @@ const executeBackup = async (scheduleId: number, manual = false): Promise<void>
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",

View file

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

View file

@ -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)

View file

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