From d1c1adaba79c0ab703ecac70c3279f6e6cbbaaa3 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 28 Oct 2025 22:34:56 +0100 Subject: [PATCH] feat: backup schedule creation form --- .../api-client/@tanstack/react-query.gen.ts | 23 + apps/client/app/api-client/sdk.gen.ts | 14 + apps/client/app/api-client/types.gen.ts | 69 +- .../app/components/create-repository-form.tsx | 8 +- apps/client/app/lib/types.ts | 4 +- .../components/create-schedule-form.tsx | 351 ++++++++ .../app/modules/details/tabs/backups.tsx | 834 ++++++------------ apps/client/app/utils/object.ts | 4 +- apps/client/package.json | 1 + .../src/modules/backups/backups.controller.ts | 7 + .../server/src/modules/backups/backups.dto.ts | 30 +- .../src/modules/backups/backups.service.ts | 9 + apps/server/src/modules/volumes/volume.dto.ts | 1 + bun.lock | 1 + 14 files changed, 746 insertions(+), 610 deletions(-) create mode 100644 apps/client/app/modules/details/components/create-schedule-form.tsx diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 053da07..52bcef0 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -28,6 +28,7 @@ import { deleteBackupSchedule, getBackupSchedule, updateBackupSchedule, + getBackupScheduleForVolume, runBackupNow, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; @@ -73,6 +74,7 @@ import type { GetBackupScheduleData, UpdateBackupScheduleData, UpdateBackupScheduleResponse, + GetBackupScheduleForVolumeData, RunBackupNowData, RunBackupNowResponse, } from "../types.gen"; @@ -842,6 +844,27 @@ export const updateBackupScheduleMutation = ( return mutationOptions; }; +export const getBackupScheduleForVolumeQueryKey = (options: Options) => + createQueryKey("getBackupScheduleForVolume", options); + +/** + * Get a backup schedule for a specific volume + */ +export const getBackupScheduleForVolumeOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getBackupScheduleForVolume({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getBackupScheduleForVolumeQueryKey(options), + }); +}; + export const runBackupNowQueryKey = (options: Options) => createQueryKey("runBackupNow", options); /** diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index d85f41f..2ce7f43 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -64,6 +64,8 @@ import type { GetBackupScheduleResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, + GetBackupScheduleForVolumeData, + GetBackupScheduleForVolumeResponses, RunBackupNowData, RunBackupNowResponses, } from "./types.gen"; @@ -416,6 +418,18 @@ export const updateBackupSchedule = ( }); }; +/** + * Get a backup schedule for a specific volume + */ +export const getBackupScheduleForVolume = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get({ + url: "/api/v1/backups/volume/{volumeId}", + ...options, + }); +}; + /** * Trigger a backup immediately for a schedule */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index c39342b..8309437 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -174,6 +174,7 @@ export type ListVolumesResponses = { username?: string; }; createdAt: number; + id: number; lastError: string | null; lastHealthCheck: number; name: string; @@ -370,6 +371,7 @@ export type GetVolumeResponses = { username?: string; }; createdAt: number; + id: number; lastError: string | null; lastHealthCheck: number; name: string; @@ -470,6 +472,7 @@ export type UpdateVolumeResponses = { username?: string; }; createdAt: number; + id: number; lastError: string | null; lastHealthCheck: number; name: string; @@ -820,15 +823,14 @@ export type ListBackupSchedulesResponses = { createdAt: number; cronExpression: string; enabled: boolean; - excludePatterns: Array; + excludePatterns: Array | null; id: number; - includePatterns: Array; + includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null; repositoryId: string; - repositoryName: string; retentionPolicy: { keepDaily?: number; keepHourly?: number; @@ -840,7 +842,6 @@ export type ListBackupSchedulesResponses = { } | null; updatedAt: number; volumeId: number; - volumeName: string; }>; }; }; @@ -881,15 +882,14 @@ export type CreateBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; - excludePatterns: Array; + excludePatterns: Array | null; id: number; - includePatterns: Array; + includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null; repositoryId: string; - repositoryName: string; retentionPolicy: { keepDaily?: number; keepHourly?: number; @@ -901,7 +901,6 @@ export type CreateBackupScheduleResponses = { } | null; updatedAt: number; volumeId: number; - volumeName: string; }; }; }; @@ -946,15 +945,14 @@ export type GetBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; - excludePatterns: Array; + excludePatterns: Array | null; id: number; - includePatterns: Array; + includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null; repositoryId: string; - repositoryName: string; retentionPolicy: { keepDaily?: number; keepHourly?: number; @@ -966,7 +964,6 @@ export type GetBackupScheduleResponses = { } | null; updatedAt: number; volumeId: number; - volumeName: string; }; }; }; @@ -1008,15 +1005,14 @@ export type UpdateBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; - excludePatterns: Array; + excludePatterns: Array | null; id: number; - includePatterns: Array; + includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null; repositoryId: string; - repositoryName: string; retentionPolicy: { keepDaily?: number; keepHourly?: number; @@ -1028,13 +1024,54 @@ export type UpdateBackupScheduleResponses = { } | null; updatedAt: number; volumeId: number; - volumeName: string; }; }; }; export type UpdateBackupScheduleResponse = UpdateBackupScheduleResponses[keyof UpdateBackupScheduleResponses]; +export type GetBackupScheduleForVolumeData = { + body?: never; + path: { + volumeId: string; + }; + query?: never; + url: "/api/v1/backups/volume/{volumeId}"; +}; + +export type GetBackupScheduleForVolumeResponses = { + /** + * Backup schedule details for the volume + */ + 200: { + createdAt: number; + cronExpression: string; + enabled: boolean; + excludePatterns: Array | null; + id: number; + includePatterns: Array | null; + lastBackupAt: number | null; + lastBackupError: string | null; + lastBackupStatus: "error" | "success" | null; + nextBackupAt: number | null; + repositoryId: string; + retentionPolicy: { + keepDaily?: number; + keepHourly?: number; + keepLast?: number; + keepMonthly?: number; + keepWeekly?: number; + keepWithinDuration?: string; + keepYearly?: number; + } | null; + updatedAt: number; + volumeId: number; + } | null; +}; + +export type GetBackupScheduleForVolumeResponse = + GetBackupScheduleForVolumeResponses[keyof GetBackupScheduleForVolumeResponses]; + export type RunBackupNowData = { body?: never; path: { diff --git a/apps/client/app/components/create-repository-form.tsx b/apps/client/app/components/create-repository-form.tsx index f6373a7..25c472f 100644 --- a/apps/client/app/components/create-repository-form.tsx +++ b/apps/client/app/components/create-repository-form.tsx @@ -16,12 +16,12 @@ export const formSchema = type({ }).and(repositoryConfigSchema); const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); -export type FormValues = typeof formSchema.inferIn; +export type RepositoryFormValues = typeof formSchema.inferIn; type Props = { - onSubmit: (values: FormValues) => void; + onSubmit: (values: RepositoryFormValues) => void; mode?: "create" | "update"; - initialValues?: Partial; + initialValues?: Partial; formId?: string; loading?: boolean; className?: string; @@ -40,7 +40,7 @@ export const CreateRepositoryForm = ({ loading, className, }: Props) => { - const form = useForm({ + const form = useForm({ resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), defaultValues: initialValues, resetOptions: { diff --git a/apps/client/app/lib/types.ts b/apps/client/app/lib/types.ts index 1d3fd54..9229dff 100644 --- a/apps/client/app/lib/types.ts +++ b/apps/client/app/lib/types.ts @@ -1,4 +1,4 @@ -import type { GetMeResponse, GetRepositoryResponse, GetVolumeResponse } from "~/api-client"; +import type { GetBackupScheduleResponse, GetMeResponse, GetRepositoryResponse, GetVolumeResponse } from "~/api-client"; export type Volume = GetVolumeResponse["volume"]; export type StatFs = GetVolumeResponse["statfs"]; @@ -7,3 +7,5 @@ export type VolumeStatus = Volume["status"]; export type User = GetMeResponse["user"]; export type Repository = GetRepositoryResponse["repository"]; + +export type BackupSchedule = GetBackupScheduleResponse["schedule"]; diff --git a/apps/client/app/modules/details/components/create-schedule-form.tsx b/apps/client/app/modules/details/components/create-schedule-form.tsx new file mode 100644 index 0000000..67853fc --- /dev/null +++ b/apps/client/app/modules/details/components/create-schedule-form.tsx @@ -0,0 +1,351 @@ +import { arktypeResolver } from "@hookform/resolvers/arktype"; +import { useQuery } from "@tanstack/react-query"; +import { type } from "arktype"; +import { useForm } from "react-hook-form"; +import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen"; +import { RepositoryIcon } from "~/components/repository-icon"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; +import type { BackupSchedule, Volume } from "~/lib/types"; +import { deepClean } from "~/utils/object"; + +const formSchema = type({ + repositoryId: "string", + excludePatterns: "string[]?", + includePatterns: "string[]?", + frequency: "string", + dailyTime: "string?", + weeklyDay: "string?", + keepLast: "number?", + keepHourly: "number?", + keepDaily: "number?", + keepWeekly: "number?", + keepMonthly: "number?", + keepYearly: "number?", +}); +const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); + +export const weeklyDays = [ + { label: "Monday", value: "1" }, + { label: "Tuesday", value: "2" }, + { label: "Wednesday", value: "3" }, + { label: "Thursday", value: "4" }, + { label: "Friday", value: "5" }, + { label: "Saturday", value: "6" }, + { label: "Sunday", value: "0" }, +]; + +export type BackupScheduleFormValues = typeof formSchema.infer; + +type Props = { + volume: Volume; + initialValues?: BackupSchedule; + onSubmit: (data: BackupScheduleFormValues) => void; + loading?: boolean; + summaryContent?: React.ReactNode; +}; + +const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => { + if (!schedule) { + return undefined; + } + + const parts = schedule.cronExpression.split(" "); + const [minutePart, hourPart, , , dayOfWeekPart] = parts; + + const isHourly = hourPart === "*"; + const isDaily = !isHourly && dayOfWeekPart === "*"; + const frequency = isHourly ? "hourly" : isDaily ? "daily" : "weekly"; + + const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`; + + const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; + + return { + repositoryId: schedule.repositoryId, + frequency, + dailyTime, + weeklyDay, + ...schedule.retentionPolicy, + }; +}; + +export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading, summaryContent }: Props) => { + console.log("Initial Values:", initialValues); + + const form = useForm({ + resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), + defaultValues: backupScheduleToFormValues(initialValues), + }); + + const { data: repositoriesData } = useQuery({ + ...listRepositoriesOptions(), + }); + + const frequency = form.watch("frequency"); + + return ( +
+ +
+ + + Backup automation + + Schedule automated backups of {volume.name} to a secure repository. + + + + ( + + Backup repository + + + + + Choose where encrypted backups for {volume.name} will be stored. + + + + )} + /> + + ( + + Backup frequency + + + + Define how often snapshots should be taken. + + + )} + /> + + {frequency !== "hourly" && ( + ( + + Execution time + + + + Time of day when the backup will run. + + + )} + /> + )} + + {frequency === "weekly" && ( + ( + + Execution day + + + + Choose which day of the week to run the backup. + + + )} + /> + )} + + + + + + Retention policy + Define how many snapshots to keep. Leave empty to keep all. + + + ( + + Keep last N snapshots + + field.onChange(Number(v.target.value))} + /> + + Keep the N most recent snapshots. + + + )} + /> + + ( + + Keep hourly + + field.onChange(Number(v.target.value))} + /> + + Keep the last N hourly snapshots. + + + )} + /> + + ( + + Keep daily + + field.onChange(Number(v.target.value))} + /> + + Keep the last N daily snapshots. + + + )} + /> + + ( + + Keep weekly + + field.onChange(Number(v.target.value))} + /> + + Keep the last N weekly snapshots. + + + )} + /> + + ( + + Keep monthly + + field.onChange(Number(v.target.value))} + /> + + Keep the last N monthly snapshots. + + + )} + /> + + ( + + Keep yearly + + field.onChange(Number(v.target.value))} + /> + + Keep the last N yearly snapshots. + + + )} + /> + + + + + +
+ + {summaryContent &&
{summaryContent}
} +
+ + ); +}; diff --git a/apps/client/app/modules/details/tabs/backups.tsx b/apps/client/app/modules/details/tabs/backups.tsx index c0ccd44..1bc6882 100644 --- a/apps/client/app/modules/details/tabs/backups.tsx +++ b/apps/client/app/modules/details/tabs/backups.tsx @@ -1,607 +1,277 @@ -import { useMemo } from "react"; -import { useForm } from "react-hook-form"; -import { OnOff } from "~/components/onoff"; +import { useMemo, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "react-router"; +import { toast } from "sonner"; +import { Database, Plus } from "lucide-react"; import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; -import { Input } from "~/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; -import { Switch } from "~/components/ui/switch"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { OnOff } from "~/components/onoff"; import type { Volume } from "~/lib/types"; - -type BackupDestination = "s3" | "sftp" | "filesystem"; -type BackupFrequency = "hourly" | "daily" | "weekly"; -type BackupEncryption = "none" | "aes256" | "gpg"; - -type BackupFormValues = { - isEnabled: boolean; - destination: BackupDestination; - frequency: BackupFrequency; - dailyTime: string; - weeklyDay: string; - retentionCopies: string; - retentionDays: string; - notifyOnFailure: boolean; - notificationWebhook: string; - encryption: BackupEncryption; - encryptionPassword: string; - s3Bucket: string; - s3Region: string; - s3PathPrefix: string; - sftpHost: string; - sftpPort: string; - sftpUsername: string; - sftpPath: string; - filesystemPath: string; -}; +import { + listRepositoriesOptions, + createBackupScheduleMutation, + updateBackupScheduleMutation, + getBackupScheduleForVolumeOptions, +} from "~/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/lib/errors"; +import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form"; type Props = { volume: Volume; }; -const weeklyDays = [ - { label: "Monday", value: "monday" }, - { label: "Tuesday", value: "tuesday" }, - { label: "Wednesday", value: "wednesday" }, - { label: "Thursday", value: "thursday" }, - { label: "Friday", value: "friday" }, - { label: "Saturday", value: "saturday" }, - { label: "Sunday", value: "sunday" }, -]; +const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => { + if (frequency === "hourly") { + return "0 * * * *"; + } + + if (!dailyTime) { + dailyTime = "02:00"; + } + + const [hours, minutes] = dailyTime.split(":"); + + if (frequency === "daily") { + return `${minutes} ${hours} * * *`; + } + + return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`; +}; export const VolumeBackupsTabContent = ({ volume }: Props) => { - const form = useForm({ - defaultValues: { - isEnabled: true, - destination: "s3", - frequency: "daily", - dailyTime: "02:00", - weeklyDay: "sunday", - retentionCopies: "7", - retentionDays: "30", - notifyOnFailure: true, - notificationWebhook: "", - encryption: "aes256", - encryptionPassword: "", - s3Bucket: "", - s3Region: "us-east-1", - s3PathPrefix: `${volume.name}/backups`, - sftpHost: "", - sftpPort: "22", - sftpUsername: "", - sftpPath: `/backups/${volume.name}`, - filesystemPath: `/var/backups/${volume.name}`, - }, + const queryClient = useQueryClient(); + + const { data: repositoriesData, isLoading: loadingRepositories } = useQuery({ + ...listRepositoriesOptions(), }); - const destination = form.watch("destination"); - const frequency = form.watch("frequency"); - const encryption = form.watch("encryption"); - const notifyOnFailure = form.watch("notifyOnFailure"); - const values = form.watch(); + const { data: existingSchedule, isLoading: loadingSchedules } = useQuery({ + ...getBackupScheduleForVolumeOptions({ path: { volumeId: volume.id.toString() } }), + }); + + const [isEnabled, setIsEnabled] = useState(existingSchedule?.enabled ?? true); + + const repositories = repositoriesData?.repositories || []; + const selectedRepository = repositories.find((r) => r.id === (existingSchedule?.repositoryId ?? "")); const summary = useMemo(() => { - const scheduleLabel = - frequency === "hourly" - ? "Every hour" - : frequency === "daily" - ? `Every day at ${values.dailyTime}` - : `Every ${values.weeklyDay.charAt(0).toUpperCase()}${values.weeklyDay.slice(1)} at ${values.dailyTime}`; + const scheduleLabel = existingSchedule ? existingSchedule.cronExpression : "Every day at 02:00"; - const destinationLabel = (() => { - if (destination === "s3") { - return `Amazon S3 → ${values.s3Bucket || ""} (${values.s3Region})`; - } - if (destination === "sftp") { - return `SFTP → ${values.sftpUsername || "user"}@${values.sftpHost || "server"}:${values.sftpPath}`; - } - return `Filesystem → ${values.filesystemPath}`; - })(); + const retentionParts: string[] = []; + if (existingSchedule?.retentionPolicy) { + const rp = existingSchedule.retentionPolicy; + if (rp.keepLast) retentionParts.push(`${rp.keepLast} last`); + if (rp.keepHourly) retentionParts.push(`${rp.keepHourly} hourly`); + if (rp.keepDaily) retentionParts.push(`${rp.keepDaily} daily`); + if (rp.keepWeekly) retentionParts.push(`${rp.keepWeekly} weekly`); + if (rp.keepMonthly) retentionParts.push(`${rp.keepMonthly} monthly`); + if (rp.keepYearly) retentionParts.push(`${rp.keepYearly} yearly`); + } return { vol: volume.name, scheduleLabel, - destinationLabel, - encryptionLabel: encryption === "none" ? "Disabled" : encryption.toUpperCase(), - retentionLabel: `${values.retentionCopies} copies \u2022 ${values.retentionDays} days`, - notificationsLabel: notifyOnFailure - ? values.notificationWebhook - ? `Webhook to ${values.notificationWebhook}` - : "Webhook pending configuration" - : "Disabled", + repositoryLabel: selectedRepository?.name || "No repository selected", + retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy", }; - }, [ - destination, - encryption, - frequency, - notifyOnFailure, - values.dailyTime, - values.filesystemPath, - values.notificationWebhook, - values.retentionCopies, - values.retentionDays, - values.s3Bucket, - values.s3Region, - values.sftpHost, - values.sftpPath, - values.sftpUsername, - values.weeklyDay, - volume.name, - ]); + }, [existingSchedule, selectedRepository, volume.name]); - const handleSubmit = (formValues: BackupFormValues) => { - console.info("Backup configuration", formValues); + const createSchedule = useMutation({ + ...createBackupScheduleMutation(), + onSuccess: () => { + toast.success("Backup schedule created successfully"); + queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); + }, + onError: (error) => { + toast.error("Failed to create backup schedule", { + description: parseError(error)?.message, + }); + }, + }); + + const updateSchedule = useMutation({ + ...updateBackupScheduleMutation(), + onSuccess: () => { + toast.success("Backup schedule updated successfully"); + queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); + }, + onError: (error) => { + toast.error("Failed to update backup schedule", { + description: parseError(error)?.message, + }); + }, + }); + + const handleSubmit = (formValues: BackupScheduleFormValues) => { + const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay); + + const retentionPolicy: Record = {}; + if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; + if (formValues.keepHourly) retentionPolicy.keepHourly = formValues.keepHourly; + if (formValues.keepDaily) retentionPolicy.keepDaily = formValues.keepDaily; + if (formValues.keepWeekly) retentionPolicy.keepWeekly = formValues.keepWeekly; + if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly; + if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly; + + if (existingSchedule) { + updateSchedule.mutate({ + path: { scheduleId: existingSchedule.id.toString() }, + body: { + repositoryId: formValues.repositoryId, + enabled: isEnabled, + cronExpression, + retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + }, + }); + } else { + createSchedule.mutate({ + body: { + volumeId: volume.id, + repositoryId: formValues.repositoryId, + enabled: true, + cronExpression, + retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + }, + }); + } + }; + + if (loadingRepositories || loadingSchedules) { + return ( + + +

Loading...

+
+
+ ); + } + + if (repositories.length === 0) { + return ( + + +
+
+
+
+
+
+ +
+
+

No repositories available

+

+ To schedule automated backups, you need to create a repository first. Repositories are secure storage + locations where your backups will be stored. +

+ +
+ + + ); + } + + const handleToggleEnabled = (enabled: boolean) => { + if (!existingSchedule) return; + + setIsEnabled(enabled); + updateSchedule.mutate({ + path: { scheduleId: existingSchedule.id.toString() }, + body: { + repositoryId: existingSchedule.repositoryId, + enabled, + cronExpression: existingSchedule.cronExpression, + retentionPolicy: existingSchedule.retentionPolicy || undefined, + }, + }); }; return ( -
-
-
- - - -
- Backup automation - - Enable scheduled snapshots and off-site replication for this volume. - -
- ( - - - - - - )} - /> -
- - ( - - Destination provider - - - - - Choose where backups for {volume.name} will be stored. - - - - )} - /> - - ( - - Backup frequency - - - - Define how often snapshots should be taken. - - - )} - /> - - {frequency !== "hourly" && ( - ( - - Execution time - - - - Time of day when the backup will run. - - - )} - /> - )} - - {frequency === "weekly" && ( - ( - - Execution day - - - - Choose which day of the week to run the backup. - - - )} - /> - )} - - ( - - Max copies to retain - - field.onChange(event.target.value)} - /> - - Oldest backups will be pruned after this many copies. - - - )} - /> - - ( - - Retention window (days) - - field.onChange(event.target.value)} - /> - - Backups older than this window will be removed. - - - )} - /> - -
- - {destination === "s3" && ( - - - Amazon S3 bucket - - Define the bucket and path where compressed archives will be uploaded. - - - - ( - - Bucket name - - - - - Ensure the bucket has versioning and lifecycle rules as needed. - - - - )} - /> - - ( - - Region - - - - AWS region where the bucket resides. - - - )} - /> - - ( - - Object prefix - - - - - Backups will be stored under this key prefix inside the bucket. - - - - )} - /> - - - )} - - {destination === "sftp" && ( - - - SFTP target - - Connect to a remote host that will receive encrypted backup archives. - - - - ( - - Hostname - - - - - - )} - /> - - ( - - Port - - - - - - )} - /> - - ( - - Username - - - - - - )} - /> - - ( - - Destination path - - - - Ensure the directory exists and has write permissions. - - - )} - /> - - - )} - - {destination === "filesystem" && ( - - - Filesystem target - Persist archives to a directory on the host running Ironmount. - - - ( - - Backup directory - - - - The directory must be mounted with sufficient capacity. - - - )} - /> - - - )} - - - - Encryption & notifications - Secure backups and stay informed when something goes wrong. - - - ( - - Encryption - - - - Protect backups at rest with optional encryption. - - - )} - /> - - {encryption !== "none" && ( - ( - - Encryption secret - - - - - Store this password securely. It will be required to restore backups. - - - - )} - /> - )} - - ( - - Failure alerts -
-
-

Webhook notifications

-

Send an HTTP POST when a backup fails.

-
- - - -
- -
- )} - /> - - {notifyOnFailure && ( - ( - - Webhook URL - - - - Ironmount will POST a JSON payload with failure details. - - - )} - /> - )} -
- - - -
-
- - - - - Runbook summary - Validate the automation before enabling it in production. - - -
-

Volume

-

{summary.vol}

-
-
-

Schedule

-

{summary.scheduleLabel}

-
-
-

Destination

-

{summary.destinationLabel}

-
-
-

Retention

-

{summary.retentionLabel}

-
-
-

Encryption

-

{summary.encryptionLabel}

-
-
-

Notifications

-

{summary.notificationsLabel}

-
-
-
-
-
-
- Preview -
-
-

Automated backups are coming soon

-

- We're working hard to bring robust backup and snapshot capabilities to Ironmount. -

-
-
- Coming soon — stay tuned! -
-
-
+ + +
+ Schedule summary + Review the backup configuration. +
+ +
+ +
+

Volume

+

{summary.vol}

+
+
+

Schedule

+

{summary.scheduleLabel}

+
+
+

Repository

+

{summary.repositoryLabel}

+
+
+

Retention

+

{summary.retentionLabel}

+
+ {existingSchedule && ( + <> +
+

Last backup

+

+ {existingSchedule.lastBackupAt + ? new Date(existingSchedule.lastBackupAt).toLocaleString() + : "Never"} +

+
+
+

Status

+

+ {existingSchedule.lastBackupStatus === "success" && "✓ Success"} + {existingSchedule.lastBackupStatus === "error" && "✗ Error"} + {!existingSchedule.lastBackupStatus && "—"} +

+
+ + )} +
+ + ) : ( + + + Schedule summary + Review the backup configuration before saving. + + +
+

Volume

+

{summary.vol}

+
+
+

Schedule

+

{summary.scheduleLabel}

+
+
+

Repository

+

{summary.repositoryLabel}

+
+
+

Retention

+

{summary.retentionLabel}

+
+
+
+ ) + } + /> ); }; diff --git a/apps/client/app/utils/object.ts b/apps/client/app/utils/object.ts index d9d0ffc..9cbb954 100644 --- a/apps/client/app/utils/object.ts +++ b/apps/client/app/utils/object.ts @@ -1,12 +1,12 @@ export function deepClean(obj: T): T { if (Array.isArray(obj)) { - return obj.map(deepClean).filter((v) => v !== undefined && v !== null) as T; + return obj.map(deepClean).filter((v) => v !== undefined && v !== null && v !== "") as T; } if (obj && typeof obj === "object") { return Object.entries(obj).reduce((acc, [key, value]) => { const cleaned = deepClean(value); - if (cleaned !== undefined) acc[key as keyof T] = cleaned; + if (cleaned !== undefined && cleaned !== "") acc[key as keyof T] = cleaned; return acc; }, {} as T); } diff --git a/apps/client/package.json b/apps/client/package.json index c8bd2ae..b808ea2 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -29,6 +29,7 @@ "arktype": "^2.1.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cron-parser": "^5.4.0", "date-fns": "^4.1.0", "dither-plugin": "^1.1.1", "isbot": "^5.1.31", diff --git a/apps/server/src/modules/backups/backups.controller.ts b/apps/server/src/modules/backups/backups.controller.ts index f22380c..61ff104 100644 --- a/apps/server/src/modules/backups/backups.controller.ts +++ b/apps/server/src/modules/backups/backups.controller.ts @@ -5,6 +5,7 @@ import { createBackupScheduleDto, deleteBackupScheduleDto, getBackupScheduleDto, + getBackupScheduleForVolumeDto, listBackupSchedulesDto, runBackupNowDto, updateBackupScheduleBody, @@ -25,6 +26,12 @@ export const backupScheduleController = new Hono() return c.json({ schedule }, 200); }) + .get("/volume/:volumeId", getBackupScheduleForVolumeDto, async (c) => { + const volumeId = c.req.param("volumeId"); + const schedule = await backupsService.getScheduleForVolume(Number(volumeId)); + + return c.json(schedule, 200); + }) .post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => { const body = c.req.valid("json"); diff --git a/apps/server/src/modules/backups/backups.dto.ts b/apps/server/src/modules/backups/backups.dto.ts index a3de602..c369058 100644 --- a/apps/server/src/modules/backups/backups.dto.ts +++ b/apps/server/src/modules/backups/backups.dto.ts @@ -16,14 +16,12 @@ export type RetentionPolicy = typeof retentionPolicySchema.infer; const backupScheduleSchema = type({ id: "number", volumeId: "number", - volumeName: "string", repositoryId: "string", - repositoryName: "string", enabled: "boolean", cronExpression: "string", retentionPolicy: retentionPolicySchema.or("null"), - excludePatterns: "string[]", - includePatterns: "string[]", + excludePatterns: "string[] | null", + includePatterns: "string[] | null", lastBackupAt: "number | null", lastBackupStatus: "'success' | 'error' | null", lastBackupError: "string | null", @@ -66,7 +64,7 @@ export const getBackupScheduleResponse = type({ schedule: backupScheduleSchema, }); -export type GetBackupScheduleResponseDto = typeof getBackupScheduleResponse.infer; +export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer; export const getBackupScheduleDto = describeRoute({ description: "Get a backup schedule by ID", @@ -84,6 +82,26 @@ export const getBackupScheduleDto = describeRoute({ }, }); +export const getBackupScheduleForVolumeResponse = backupScheduleSchema.or("null"); + +export type GetBackupScheduleForVolumeResponseDto = typeof getBackupScheduleForVolumeResponse.infer; + +export const getBackupScheduleForVolumeDto = describeRoute({ + description: "Get a backup schedule for a specific volume", + tags: ["Backups"], + operationId: "getBackupScheduleForVolume", + responses: { + 200: { + description: "Backup schedule details for the volume", + content: { + "application/json": { + schema: resolver(getBackupScheduleForVolumeResponse), + }, + }, + }, + }, +}); + /** * Create a new backup schedule */ @@ -105,6 +123,8 @@ export const createBackupScheduleResponse = type({ schedule: backupScheduleSchema, }); +export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer; + export const createBackupScheduleDto = describeRoute({ description: "Create a new backup schedule for a volume", operationId: "createBackupSchedule", diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts index 79ab30b..6de99e5 100644 --- a/apps/server/src/modules/backups/backups.service.ts +++ b/apps/server/src/modules/backups/backups.service.ts @@ -247,6 +247,14 @@ const getSchedulesToExecute = async () => { return schedulesToRun; }; +const getScheduleForVolume = async (volumeId: number) => { + const schedule = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.volumeId, volumeId), + }); + + return schedule ?? null; +}; + export const backupsService = { listSchedules, getSchedule, @@ -255,4 +263,5 @@ export const backupsService = { deleteSchedule, executeBackup, getSchedulesToExecute, + getScheduleForVolume, }; diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index f07f699..25a4ded 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -3,6 +3,7 @@ import { type } from "arktype"; import { describeRoute, resolver } from "hono-openapi"; const volumeSchema = type({ + id: "number", name: "string", path: "string", type: type.valueOf(BACKEND_TYPES), diff --git a/bun.lock b/bun.lock index e28d3fe..cdc496f 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "arktype": "^2.1.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cron-parser": "^5.4.0", "date-fns": "^4.1.0", "dither-plugin": "^1.1.1", "isbot": "^5.1.31",