parent
6354705626
commit
866a3c63e8
13 changed files with 143 additions and 16 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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, " ");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const scheduleQueries = {
|
|||
lastBackupStatus?: BackupStatusType;
|
||||
lastBackupAt?: number;
|
||||
lastBackupError?: string | null;
|
||||
nextBackupAt?: number;
|
||||
nextBackupAt?: number | null;
|
||||
},
|
||||
) => {
|
||||
return db
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ export const getCronExpression = (
|
|||
monthlyDays?: string[],
|
||||
cronExpression?: string,
|
||||
): string => {
|
||||
if (frequency === "manual") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (frequency === "cron" && cronExpression) {
|
||||
return cronExpression;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue