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>
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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, " ");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue