diff --git a/apps/client/app/components/status-dot.tsx b/apps/client/app/components/status-dot.tsx
index 5da576b..721c29d 100644
--- a/apps/client/app/components/status-dot.tsx
+++ b/apps/client/app/components/status-dot.tsx
@@ -6,7 +6,7 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
const statusMapping = {
mounted: {
color: "bg-green-500",
- colorLight: "bg-green-400",
+ colorLight: "bg-emerald-400",
animated: true,
},
unmounted: {
@@ -16,7 +16,7 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
},
error: {
color: "bg-red-500",
- colorLight: "bg-red-400",
+ colorLight: "bg-amber-700",
animated: true,
},
unknown: {
diff --git a/apps/client/app/modules/details/components/healthchecks-card.tsx b/apps/client/app/modules/details/components/healthchecks-card.tsx
index 720d0ec..4798f39 100644
--- a/apps/client/app/modules/details/components/healthchecks-card.tsx
+++ b/apps/client/app/modules/details/components/healthchecks-card.tsx
@@ -4,6 +4,7 @@ import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Switch } from "~/components/ui/switch";
import type { Volume } from "~/lib/types";
+import { cn } from "~/lib/utils";
type Props = {
volume: Volume;
@@ -21,14 +22,25 @@ export const HealthchecksCard = ({ volume }: Props) => {
Health Checks
- {volume.lastError && {volume.lastError}}
- {volume.status === "mounted" && Healthy}
+ {volume.lastError && {volume.lastError}}
+ {volume.status === "mounted" && Healthy}
{volume.status !== "unmounted" && (
Checked {timeAgo || "never"}
)}
-
+
+
Remount on error
-
+
+ {volume.autoRemount ? "Enabled" : "Paused"}
+ {}} />
+
diff --git a/apps/client/app/modules/details/tabs/backups.tsx b/apps/client/app/modules/details/tabs/backups.tsx
new file mode 100644
index 0000000..6debd03
--- /dev/null
+++ b/apps/client/app/modules/details/tabs/backups.tsx
@@ -0,0 +1,588 @@
+import { useMemo } from "react";
+import { useForm } from "react-hook-form";
+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 type { Volume } from "~/lib/types";
+import { cn } from "~/lib/utils";
+
+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;
+};
+
+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" },
+];
+
+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 destination = form.watch("destination");
+ const frequency = form.watch("frequency");
+ const encryption = form.watch("encryption");
+ const notifyOnFailure = form.watch("notifyOnFailure");
+ const values = form.watch();
+
+ 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 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}`;
+ })();
+
+ 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",
+ };
+ }, [
+ 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,
+ ]);
+
+ const handleSubmit = (formValues: BackupFormValues) => {
+ console.info("Backup configuration", formValues);
+ };
+
+ return (
+
+
+
+
+
+
+ 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}
+
+
+
+
+ );
+};
diff --git a/apps/client/app/routes/details.tsx b/apps/client/app/routes/details.tsx
index c867353..4bd1de4 100644
--- a/apps/client/app/routes/details.tsx
+++ b/apps/client/app/routes/details.tsx
@@ -14,6 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { VolumeIcon } from "~/components/volume-icon";
import { parseError } from "~/lib/errors";
import { cn } from "~/lib/utils";
+import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups";
import { DockerTabContent } from "~/modules/details/tabs/docker";
import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
import type { Route } from "./+types/details";
@@ -131,7 +132,8 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
Configuration
- Docker usage
+ Docker
+ Backups
@@ -139,6 +141,9 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
+
+
+
>
);