From cce2d356fe0eac6cfaca9a214c90bf3d28adbd6f Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 30 Oct 2025 18:18:11 +0100 Subject: [PATCH] feat: backup schedule frontend --- apps/client/app/api-client/types.gen.ts | 4 +- apps/client/app/app.css | 1 + .../client/app/components/grid-background.tsx | 3 +- apps/client/app/components/layout.tsx | 18 +- .../components/create-schedule-form.tsx | 47 +++- .../details/components/schedule-summary.tsx | 237 ++++++++++++++++++ .../app/modules/details/tabs/backups.tsx | 144 +++-------- .../modules/repositories/tabs/snapshots.tsx | 24 +- apps/client/app/root.tsx | 2 +- .../src/modules/backups/backups.service.ts | 5 + .../repositories/repositories.controller.ts | 7 +- .../modules/repositories/repositories.dto.ts | 4 + .../repositories/repositories.service.ts | 22 +- biome.json | 7 +- 14 files changed, 379 insertions(+), 146 deletions(-) create mode 100644 apps/client/app/modules/details/components/schedule-summary.tsx diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index c3839c0..66073ef 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -787,7 +787,9 @@ export type ListSnapshotsData = { path: { name: string; }; - query?: never; + query?: { + volumeId?: number; + }; url: "/api/v1/repositories/{name}/snapshots"; }; diff --git a/apps/client/app/app.css b/apps/client/app/app.css index 7458c24..8e06eb4 100644 --- a/apps/client/app/app.css +++ b/apps/client/app/app.css @@ -17,6 +17,7 @@ body { width: 100%; position: relative; overscroll-behavior: none; + scrollbar-width: thin; @media (prefers-color-scheme: dark) { color-scheme: dark; diff --git a/apps/client/app/components/grid-background.tsx b/apps/client/app/components/grid-background.tsx index fd51ca0..5d4c687 100644 --- a/apps/client/app/components/grid-background.tsx +++ b/apps/client/app/components/grid-background.tsx @@ -18,8 +18,7 @@ export function GridBackground({ children, className, containerClassName }: Grid containerClassName, )} > -
-
{children}
+
{children}
); } diff --git a/apps/client/app/components/layout.tsx b/apps/client/app/components/layout.tsx index 93459ef..abb3336 100644 --- a/apps/client/app/components/layout.tsx +++ b/apps/client/app/components/layout.tsx @@ -34,11 +34,11 @@ export default function Layout({ loaderData }: Route.ComponentProps) { }); return ( - + - -
-
+
+
+
@@ -69,10 +69,12 @@ export default function Layout({ loaderData }: Route.ComponentProps) { )}
-
- -
- + +
+ +
+
+
); } diff --git a/apps/client/app/modules/details/components/create-schedule-form.tsx b/apps/client/app/modules/details/components/create-schedule-form.tsx index b861ef6..dd48529 100644 --- a/apps/client/app/modules/details/components/create-schedule-form.tsx +++ b/apps/client/app/modules/details/components/create-schedule-form.tsx @@ -73,9 +73,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo }; }; -export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading, summaryContent }: Props) => { - console.log("Initial Values:", initialValues); - +export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading }: Props) => { const form = useForm({ resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), defaultValues: backupScheduleToFormValues(initialValues), @@ -86,6 +84,7 @@ export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading, s }); const frequency = form.watch("frequency"); + const formValues = form.watch(); return (
@@ -343,8 +342,46 @@ export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading, s
- - {summaryContent &&
{summaryContent}
} +
+ + +
+ Schedule summary + Review the backup configuration. +
+
+ +
+

Volume

+

{volume.name}

+
+
+

Schedule

+

+ {frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : "-"} +

+
+
+

Repository

+

+ {repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"} +

+
+
+

Retention

+

+ {Object.entries(formValues) + .filter(([key, value]) => key.startsWith("keep") && Boolean(value)) + .map(([key, value]) => { + const label = key.replace("keep", "").toLowerCase(); + return `${value} ${label}`; + }) + .join(", ") || "-"} +

+
+
+
+
); diff --git a/apps/client/app/modules/details/components/schedule-summary.tsx b/apps/client/app/modules/details/components/schedule-summary.tsx new file mode 100644 index 0000000..63d7319 --- /dev/null +++ b/apps/client/app/modules/details/components/schedule-summary.tsx @@ -0,0 +1,237 @@ +import { useQuery } from "@tanstack/react-query"; +import { Calendar, Clock, Database, FolderTree, HardDrive, Pencil } from "lucide-react"; +import { useMemo } from "react"; +import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen"; +import { ByteSize } from "~/components/bytes-size"; +import { OnOff } from "~/components/onoff"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; +import type { BackupSchedule, Repository, Volume } from "~/lib/types"; +import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots"; + +type Props = { + volume: Volume; + schedule: BackupSchedule; + repository: Repository; + handleToggleEnabled: (enabled: boolean) => void; + setIsEditMode: (isEdit: boolean) => void; +}; + +export const ScheduleSummary = (props: Props) => { + const { volume, schedule, repository, handleToggleEnabled, setIsEditMode } = props; + + const { data: snapshots, isLoading: loadingSnapshots } = useQuery({ + ...listSnapshotsOptions({ + path: { name: repository.name }, + query: { volumeId: volume.id }, + }), + refetchInterval: 10000, + refetchOnWindowFocus: true, + }); + + const summary = useMemo(() => { + const scheduleLabel = schedule ? schedule.cronExpression : "-"; + + const retentionParts: string[] = []; + if (schedule?.retentionPolicy) { + const rp = schedule.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, + repositoryLabel: schedule.repositoryId || "No repository selected", + retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy", + }; + }, [schedule, volume.name]); + + return ( +
+ + +
+ Backup schedule + Automated backup configuration for {volume.name} +
+
+ + +
+
+ +
+

Schedule

+

{summary.scheduleLabel}

+
+
+

Repository

+

{summary.repositoryLabel}

+
+
+

Last backup

+

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

+
+
+

Next backup

+

+ {schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleString() : "Never"} +

+
+ +
+

Status

+

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

+
+
+
+ + + +
+
+ Snapshots + + Backup snapshots for this volume. Total: {snapshots?.snapshots.length} + +
+
+
+ {loadingSnapshots && !snapshots ? ( + +

Loading snapshots...

+
+ ) : !snapshots ? ( + +
+
+
+
+
+ +
+
+
+

No snapshots yet

+

+ Snapshots are point-in-time backups of your data. The next scheduled backup will create the first + snapshot. +

+
+ + ) : ( + <> +
+ + + + Snapshot ID + Date & Time + Size + Duration + Paths + + + + + {snapshots.snapshots.map((s) => ( + + +
+ + {s.short_id} +
+
+ +
+ + {new Date(s.time).toLocaleString()} +
+
+ +
+ + + + +
+
+ +
+ + + {formatSnapshotDuration(s.duration / 1000)} + +
+
+ +
+ +
+ + {s.paths[0].split("/").filter(Boolean).pop() || "/"} + + + + + +{s.paths.length - 1} + + + +
+ {s.paths.slice(1).map((path) => ( +
+ {path} +
+ ))} +
+
+
+
+
+
+
+ ))} +
+
+
+
+ {`Showing ${snapshots.snapshots.length} of ${snapshots.snapshots.length}`} + + Total size:  + + sum + s.size, 0)} + base={1024} + maximumFractionDigits={1} + /> + + +
+ + )} + +
+ ); +}; diff --git a/apps/client/app/modules/details/tabs/backups.tsx b/apps/client/app/modules/details/tabs/backups.tsx index a6331d0..003b55f 100644 --- a/apps/client/app/modules/details/tabs/backups.tsx +++ b/apps/client/app/modules/details/tabs/backups.tsx @@ -1,11 +1,10 @@ -import { useMemo, useState } from "react"; +import { 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, CardHeader, CardTitle } from "~/components/ui/card"; -import { OnOff } from "~/components/onoff"; +import { Card, CardContent } from "~/components/ui/card"; import type { Volume } from "~/lib/types"; import { listRepositoriesOptions, @@ -14,6 +13,7 @@ import { } from "~/api-client/@tanstack/react-query.gen"; import { parseError } from "~/lib/errors"; import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form"; +import { ScheduleSummary } from "../components/schedule-summary"; type Props = { volume: Volume; @@ -39,6 +39,7 @@ const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: st export const VolumeBackupsTabContent = ({ volume }: Props) => { const queryClient = useQueryClient(); + const [isEditMode, setIsEditMode] = useState(false); const { data: repositoriesData, isLoading: loadingRepositories } = useQuery({ ...listRepositoriesOptions(), @@ -48,32 +49,7 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => { ...getBackupScheduleForVolumeOptions({ path: { volumeId: volume.id.toString() } }), }); - const [isEnabled, setIsEnabled] = useState(existingSchedule?.enabled ?? true); - const repositories = repositoriesData || []; - const selectedRepository = repositories.find((r) => r.id === (existingSchedule?.repositoryId ?? "")); - - const summary = useMemo(() => { - const scheduleLabel = existingSchedule ? existingSchedule.cronExpression : "Every day at 02:00"; - - 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, - repositoryLabel: selectedRepository?.name || "No repository selected", - retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy", - }; - }, [existingSchedule, selectedRepository, volume.name]); const upsertSchedule = useMutation({ ...upsertBackupScheduleMutation(), @@ -104,11 +80,15 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => { body: { volumeId: volume.id, repositoryId: formValues.repositoryId, - enabled: existingSchedule ? isEnabled : true, + enabled: existingSchedule?.enabled ?? true, cronExpression, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, }, }); + + if (existingSchedule) { + setIsEditMode(false); + } }; if (loadingRepositories || loadingSchedules) { @@ -154,7 +134,6 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => { const handleToggleEnabled = (enabled: boolean) => { if (!existingSchedule) return; - setIsEnabled(enabled); upsertSchedule.mutate({ body: { volumeId: existingSchedule.volumeId, @@ -166,87 +145,30 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => { }); }; + const repository = repositories.find((repo) => repo.id === existingSchedule?.repositoryId); + + if (existingSchedule && repository && !isEditMode) { + return ( + + ); + } + return ( - - -
- 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}

-
-
-
- ) - } - /> +
+ {existingSchedule && isEditMode && ( +
+ +
+ )} + +
); }; diff --git a/apps/client/app/modules/repositories/tabs/snapshots.tsx b/apps/client/app/modules/repositories/tabs/snapshots.tsx index 1e14b86..cb346bf 100644 --- a/apps/client/app/modules/repositories/tabs/snapshots.tsx +++ b/apps/client/app/modules/repositories/tabs/snapshots.tsx @@ -18,6 +18,18 @@ type Props = { type Snapshot = ListSnapshotsResponse["snapshots"][0]; +export const formatSnapshotDuration = (seconds: number) => { + const duration = intervalToDuration({ start: 0, end: seconds * 1000 }); + const parts: string[] = []; + + if (duration.days) parts.push(`${duration.days}d`); + if (duration.hours) parts.push(`${duration.hours}h`); + if (duration.minutes) parts.push(`${duration.minutes}m`); + if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`); + + return parts.join(" "); +}; + export const RepositorySnapshotsTabContent = ({ repository }: Props) => { const [searchQuery, setSearchQuery] = useState(""); @@ -41,18 +53,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => { const hasNoSnapshots = snapshots.length === 0; const hasNoFilteredSnapshots = filteredSnapshots.length === 0 && !hasNoSnapshots; - const formatSnapshotDuration = (seconds: number) => { - const duration = intervalToDuration({ start: 0, end: seconds * 1000 }); - const parts: string[] = []; - - if (duration.days) parts.push(`${duration.days}d`); - if (duration.hours) parts.push(`${duration.hours}h`); - if (duration.minutes) parts.push(`${duration.minutes}m`); - if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`); - - return parts.join(" "); - }; - if (repository.status === "error") { return ( diff --git a/apps/client/app/root.tsx b/apps/client/app/root.tsx index 198b5b2..069e390 100644 --- a/apps/client/app/root.tsx +++ b/apps/client/app/root.tsx @@ -45,7 +45,7 @@ export function Layout({ children }: { children: React.ReactNode }) { - + {children} diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts index b1b6ae2..cfeda74 100644 --- a/apps/server/src/modules/backups/backups.service.ts +++ b/apps/server/src/modules/backups/backups.service.ts @@ -155,6 +155,11 @@ const executeBackup = async (scheduleId: number) => { throw new NotFoundError("Backup schedule not found"); } + if (!schedule.enabled) { + logger.info(`Backup schedule ${scheduleId} is disabled. Skipping execution.`); + return; + } + const volume = await db.query.volumesTable.findFirst({ where: eq(volumesTable.id, schedule.volumeId), }); diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index c74c8e5..1ea4674 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -7,6 +7,7 @@ import { getRepositoryDto, listRepositoriesDto, listSnapshotsDto, + listSnapshotsFilters, type DeleteRepositoryDto, type GetRepositoryDto, type ListRepositoriesDto, @@ -38,9 +39,11 @@ export const repositoriesController = new Hono() return c.json({ message: "Repository deleted" }, 200); }) - .get("/:name/snapshots", listSnapshotsDto, async (c) => { + .get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => { const { name } = c.req.param(); - const res = await repositoriesService.listSnapshots(name); + const { volumeId } = c.req.valid("query"); + + const res = await repositoriesService.listSnapshots(name, Number(volumeId)); const snapshots = res.map((snapshot) => { const { summary } = snapshot; diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index 02206fc..4f786b7 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -145,6 +145,10 @@ const listSnapshotsResponse = type({ export type ListSnapshotsDto = typeof listSnapshotsResponse.infer; +export const listSnapshotsFilters = type({ + volumeId: "string?", +}); + export const listSnapshotsDto = describeRoute({ description: "List all snapshots in a repository", tags: ["Repositories"], diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index 3530f78..d83ce61 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -4,10 +4,11 @@ import { eq } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { db } from "../../db/db"; -import { repositoriesTable } from "../../db/schema"; +import { repositoriesTable, volumesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; +import { getVolumePath } from "../volumes/helpers"; const listRepositories = async () => { const repositories = await db.query.repositoriesTable.findMany({}); @@ -105,7 +106,7 @@ const deleteRepository = async (name: string) => { await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name)); }; -const listSnapshots = async (name: string) => { +const listSnapshots = async (name: string, volumeId?: number) => { const repository = await db.query.repositoriesTable.findFirst({ where: eq(repositoriesTable.name, name), }); @@ -114,7 +115,22 @@ const listSnapshots = async (name: string) => { throw new NotFoundError("Repository not found"); } - const snapshots = await restic.snapshots(repository.config); + let snapshots = await restic.snapshots(repository.config); + + if (volumeId) { + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.id, volumeId), + }); + + if (!volume) { + throw new NotFoundError("Volume not found"); + } + + snapshots = snapshots.filter((snapshot) => { + return snapshot.paths.some((path) => path.includes(getVolumePath(volume.name))); + }); + } + return snapshots; }; diff --git a/biome.json b/biome.json index c8fd686..478d60b 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.0/schema.json", "vcs": { "enabled": false, "clientKind": "git", @@ -31,5 +31,10 @@ "organizeImports": "off" } } + }, + "css": { + "parser": { + "tailwindDirectives": true + } } }