+
+
+
@@ -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 (
-
- {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
+ }
}
}