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 ( +
+
+ + + +
+ Backup automation + Enable scheduled snapshots and off-site replication for this volume. +
+ ( + + +
+ {field.value ? "Enabled" : "Paused"} + +
+
+
+ )} + /> +
+ + ( + + 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}

+
+
+
+
+ ); +}; 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) { + + + );