From 3befa127d77bb0a869138c195878d1810b4d6985 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 1 Nov 2025 17:49:40 +0100 Subject: [PATCH] refactor: frontend components consolidation --- apps/client/app/api-client/types.gen.ts | 217 ++++++++++++++---- apps/client/app/components/app-sidebar.tsx | 4 +- .../components/create-repository-dialog.tsx | 4 +- apps/client/app/components/empty-state.tsx | 68 ++---- .../app/{ => modules/auth}/routes/login.tsx | 0 .../{ => modules/auth}/routes/onboarding.tsx | 0 .../components/create-schedule-form.tsx | 0 .../components/schedule-summary.tsx | 0 .../backups/routes/backup-details.tsx} | 23 +- .../backups/routes/backups.tsx} | 60 ++--- .../modules/backups/routes/create-backup.tsx | 177 ++++++++++++++ .../app/modules/details/tabs/backups.tsx | 199 ---------------- .../repositories/routes/repositories.tsx | 37 +-- .../components/healthchecks-card.tsx | 0 .../components/storage-chart.tsx | 0 .../volumes/routes/volume-details.tsx} | 17 +- .../volumes/routes/volumes.tsx} | 42 ++-- .../{details => volumes}/tabs/docker.tsx | 0 .../{details => volumes}/tabs/files.tsx | 0 .../{details => volumes}/tabs/info.tsx | 0 apps/client/app/routes.ts | 13 +- apps/client/app/utils/utils.ts | 17 ++ apps/server/src/core/constants.ts | 3 +- apps/server/src/db/db.ts | 1 + .../server/src/modules/backups/backups.dto.ts | 22 +- .../src/modules/backups/backups.service.ts | 8 +- .../src/modules/volumes/volume.controller.ts | 9 +- apps/server/src/modules/volumes/volume.dto.ts | 5 +- apps/server/src/utils/restic.ts | 4 +- docker-compose.yml | 2 - 30 files changed, 483 insertions(+), 449 deletions(-) rename apps/client/app/{ => modules/auth}/routes/login.tsx (100%) rename apps/client/app/{ => modules/auth}/routes/onboarding.tsx (100%) rename apps/client/app/modules/{details => backups}/components/create-schedule-form.tsx (100%) rename apps/client/app/modules/{details => backups}/components/schedule-summary.tsx (100%) rename apps/client/app/{routes/schedule-details.tsx => modules/backups/routes/backup-details.tsx} (89%) rename apps/client/app/{routes/backup-jobs.tsx => modules/backups/routes/backups.tsx} (74%) create mode 100644 apps/client/app/modules/backups/routes/create-backup.tsx delete mode 100644 apps/client/app/modules/details/tabs/backups.tsx rename apps/client/app/modules/{details => volumes}/components/healthchecks-card.tsx (100%) rename apps/client/app/modules/{details => volumes}/components/storage-chart.tsx (100%) rename apps/client/app/{routes/details.tsx => modules/volumes/routes/volume-details.tsx} (87%) rename apps/client/app/{routes/home.tsx => modules/volumes/routes/volumes.tsx} (82%) rename apps/client/app/modules/{details => volumes}/tabs/docker.tsx (100%) rename apps/client/app/modules/{details => volumes}/tabs/files.tsx (100%) rename apps/client/app/modules/{details => volumes}/tabs/info.tsx (100%) create mode 100644 apps/client/app/utils/utils.ts diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 707e063..034c25e 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -136,50 +136,47 @@ export type ListVolumesResponses = { /** * A list of volumes */ - 200: { - volumes: Array<{ - autoRemount: boolean; - config: - | { - backend: "directory"; - } - | { - backend: "nfs"; - exportPath: string; - server: string; - version: "3" | "4" | "4.1"; - port?: number; - } - | { - backend: "smb"; - password: string; - server: string; - share: string; - username: string; - vers?: "1.0" | "2.0" | "2.1" | "3.0"; - port?: number; - domain?: string; - } - | { - backend: "webdav"; - path: string; - server: string; - port?: number; - password?: string; - ssl?: boolean; - username?: string; - }; - createdAt: number; - id: number; - lastError: string | null; - lastHealthCheck: number; - name: string; - path: string; - status: "error" | "mounted" | "unmounted"; - type: "directory" | "nfs" | "smb" | "webdav"; - updatedAt: number; - }>; - }; + 200: Array<{ + autoRemount: boolean; + config: + | { + backend: "directory"; + } + | { + backend: "nfs"; + exportPath: string; + server: string; + version: "3" | "4" | "4.1"; + port?: number; + } + | { + backend: "smb"; + password: string; + server: string; + share: string; + username: string; + vers?: "1.0" | "2.0" | "2.1" | "3.0"; + port?: number; + domain?: string; + } + | { + backend: "webdav"; + path: string; + server: string; + port?: number; + password?: string; + ssl?: boolean; + username?: string; + }; + createdAt: number; + id: number; + lastError: string | null; + lastHealthCheck: number; + name: string; + status: "error" | "mounted" | "unmounted"; + type: "directory" | "nfs" | "smb" | "webdav"; + updatedAt: number; + }>; }; export type ListVolumesResponse = ListVolumesResponses[keyof ListVolumesResponses]; @@ -264,7 +261,6 @@ export type CreateVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; - path: string; status: "error" | "mounted" | "unmounted"; type: "directory" | "nfs" | "smb" | "webdav"; updatedAt: number; @@ -406,7 +402,6 @@ export type GetVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; - path: string; status: "error" | "mounted" | "unmounted"; type: "directory" | "nfs" | "smb" | "webdav"; updatedAt: number; @@ -505,7 +500,6 @@ export type UpdateVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; - path: string; status: "error" | "mounted" | "unmounted"; type: "directory" | "nfs" | "smb" | "webdav"; updatedAt: number; @@ -900,6 +894,29 @@ export type ListBackupSchedulesResponses = { lastBackupError: string | null; lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null; + repository: { + compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null; + config: + | { + accessKeyId: string; + backend: "s3"; + bucket: string; + endpoint: string; + secretAccessKey: string; + } + | { + backend: "local"; + name: string; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + status: "error" | "healthy" | "unknown" | null; + type: "local" | "s3"; + updatedAt: number; + }; repositoryId: string; retentionPolicy: { keepDaily?: number; @@ -911,6 +928,47 @@ export type ListBackupSchedulesResponses = { keepYearly?: number; } | null; updatedAt: number; + volume: { + autoRemount: boolean; + config: + | { + backend: "directory"; + } + | { + backend: "nfs"; + exportPath: string; + server: string; + version: "3" | "4" | "4.1"; + port?: number; + } + | { + backend: "smb"; + password: string; + server: string; + share: string; + username: string; + vers?: "1.0" | "2.0" | "2.1" | "3.0"; + port?: number; + domain?: string; + } + | { + backend: "webdav"; + path: string; + server: string; + port?: number; + password?: string; + ssl?: boolean; + username?: string; + }; + createdAt: number; + id: number; + lastError: string | null; + lastHealthCheck: number; + name: string; + status: "error" | "mounted" | "unmounted"; + type: "directory" | "nfs" | "smb" | "webdav"; + updatedAt: number; + }; volumeId: number; }>; }; @@ -1088,7 +1146,6 @@ export type GetBackupScheduleResponses = { lastError: string | null; lastHealthCheck: number; name: string; - path: string; status: "error" | "mounted" | "unmounted"; type: "directory" | "nfs" | "smb" | "webdav"; updatedAt: number; @@ -1180,6 +1237,29 @@ export type GetBackupScheduleForVolumeResponses = { lastBackupError: string | null; lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null; + repository: { + compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null; + config: + | { + accessKeyId: string; + backend: "s3"; + bucket: string; + endpoint: string; + secretAccessKey: string; + } + | { + backend: "local"; + name: string; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + status: "error" | "healthy" | "unknown" | null; + type: "local" | "s3"; + updatedAt: number; + }; repositoryId: string; retentionPolicy: { keepDaily?: number; @@ -1191,6 +1271,47 @@ export type GetBackupScheduleForVolumeResponses = { keepYearly?: number; } | null; updatedAt: number; + volume: { + autoRemount: boolean; + config: + | { + backend: "directory"; + } + | { + backend: "nfs"; + exportPath: string; + server: string; + version: "3" | "4" | "4.1"; + port?: number; + } + | { + backend: "smb"; + password: string; + server: string; + share: string; + username: string; + vers?: "1.0" | "2.0" | "2.1" | "3.0"; + port?: number; + domain?: string; + } + | { + backend: "webdav"; + path: string; + server: string; + port?: number; + password?: string; + ssl?: boolean; + username?: string; + }; + createdAt: number; + id: number; + lastError: string | null; + lastHealthCheck: number; + name: string; + status: "error" | "mounted" | "unmounted"; + type: "directory" | "nfs" | "smb" | "webdav"; + updatedAt: number; + }; volumeId: number; } | null; }; diff --git a/apps/client/app/components/app-sidebar.tsx b/apps/client/app/components/app-sidebar.tsx index 7107db4..015a35b 100644 --- a/apps/client/app/components/app-sidebar.tsx +++ b/apps/client/app/components/app-sidebar.tsx @@ -26,8 +26,8 @@ const items = [ icon: Database, }, { - title: "Backup jobs", - url: "/backup-jobs", + title: "Backups", + url: "/backups", icon: CalendarClock, }, ]; diff --git a/apps/client/app/components/create-repository-dialog.tsx b/apps/client/app/components/create-repository-dialog.tsx index 5ee614c..88aa1dd 100644 --- a/apps/client/app/components/create-repository-dialog.tsx +++ b/apps/client/app/components/create-repository-dialog.tsx @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import { Database } from "lucide-react"; +import { Plus } from "lucide-react"; import { useId } from "react"; import { toast } from "sonner"; import { createRepositoryMutation } from "~/api-client/@tanstack/react-query.gen"; @@ -34,7 +34,7 @@ export const CreateRepositoryDialog = ({ open, setOpen }: Props) => { diff --git a/apps/client/app/components/empty-state.tsx b/apps/client/app/components/empty-state.tsx index 2cf983c..4acf26f 100644 --- a/apps/client/app/components/empty-state.tsx +++ b/apps/client/app/components/empty-state.tsx @@ -1,56 +1,32 @@ -import { Database, HardDrive, HeartPulse, Plus } from "lucide-react"; -import { CreateVolumeDialog } from "./create-volume-dialog"; -import { useState } from "react"; +import { Card } from "./ui/card"; -export function EmptyState() { - const [createVolumeOpen, setCreateVolumeOpen] = useState(false); +type EmptyStateProps = { + title?: string; + description?: string; + icon: React.ComponentType>; + button?: React.ReactNode; +}; + +export function EmptyState(props: EmptyStateProps) { + const { title, description, icon: Cicon, button } = props; return ( -
-
-
-
-
- -
- -
-
- -
-

No volumes yet

-

- Get started by creating your first volume. Manage and monitor all your storage backends in one place with - advanced features like automatic mounting and health checks. -

-
- - -
-
-
- + +
+
+
+
-

Multiple Backends

-

Support for local, NFS, and SMB storage

-
- -
-
- +
+
-

Auto Mounting

-

Automatic lifecycle management

- -
-
- -
-

Real-time Monitoring

-

Live status and health checks

+
+

{title}

+

{description}

+ {button}
-
+ ); } diff --git a/apps/client/app/routes/login.tsx b/apps/client/app/modules/auth/routes/login.tsx similarity index 100% rename from apps/client/app/routes/login.tsx rename to apps/client/app/modules/auth/routes/login.tsx diff --git a/apps/client/app/routes/onboarding.tsx b/apps/client/app/modules/auth/routes/onboarding.tsx similarity index 100% rename from apps/client/app/routes/onboarding.tsx rename to apps/client/app/modules/auth/routes/onboarding.tsx diff --git a/apps/client/app/modules/details/components/create-schedule-form.tsx b/apps/client/app/modules/backups/components/create-schedule-form.tsx similarity index 100% rename from apps/client/app/modules/details/components/create-schedule-form.tsx rename to apps/client/app/modules/backups/components/create-schedule-form.tsx diff --git a/apps/client/app/modules/details/components/schedule-summary.tsx b/apps/client/app/modules/backups/components/schedule-summary.tsx similarity index 100% rename from apps/client/app/modules/details/components/schedule-summary.tsx rename to apps/client/app/modules/backups/components/schedule-summary.tsx diff --git a/apps/client/app/routes/schedule-details.tsx b/apps/client/app/modules/backups/routes/backup-details.tsx similarity index 89% rename from apps/client/app/routes/schedule-details.tsx rename to apps/client/app/modules/backups/routes/backup-details.tsx index 102e644..02e19bb 100644 --- a/apps/client/app/routes/schedule-details.tsx +++ b/apps/client/app/modules/backups/routes/backup-details.tsx @@ -10,26 +10,9 @@ import { runBackupNowMutation, } from "~/api-client/@tanstack/react-query.gen"; import { parseError } from "~/lib/errors"; -import { CreateScheduleForm, type BackupScheduleFormValues } from "~/modules/details/components/create-schedule-form"; -import { ScheduleSummary } from "~/modules/details/components/schedule-summary"; - -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"}`; -}; +import { getCronExpression } from "~/utils/utils"; +import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form"; +import { ScheduleSummary } from "../components/schedule-summary"; export default function ScheduleDetailsPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); diff --git a/apps/client/app/routes/backup-jobs.tsx b/apps/client/app/modules/backups/routes/backups.tsx similarity index 74% rename from apps/client/app/routes/backup-jobs.tsx rename to apps/client/app/modules/backups/routes/backups.tsx index 8be06ff..c70d57a 100644 --- a/apps/client/app/routes/backup-jobs.tsx +++ b/apps/client/app/modules/backups/routes/backups.tsx @@ -1,14 +1,34 @@ import { useQuery } from "@tanstack/react-query"; import { CalendarClock, Database, HardDrive, Plus } from "lucide-react"; import { Link } from "react-router"; +import { listBackupSchedules } from "~/api-client"; import { listBackupSchedulesOptions } from "~/api-client/@tanstack/react-query.gen"; +import { EmptyState } from "~/components/empty-state"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import type { Route } from "./+types/backups"; -export default function BackupJobsPage() { +export function meta(_: Route.MetaArgs) { + return [ + { title: "Ironmount" }, + { + name: "description", + content: "Create, manage, monitor, and automate your Docker volumes with ease.", + }, + ]; +} + +export const clientLoader = async () => { + const jobs = await listBackupSchedules(); + if (jobs.data) return jobs.data; + return []; +}; + +export default function Backups({ loaderData }: Route.ComponentProps) { const { data: schedules, isLoading } = useQuery({ ...listBackupSchedulesOptions(), + initialData: loaderData, refetchInterval: 10000, refetchOnWindowFocus: true, }); @@ -23,31 +43,19 @@ export default function BackupJobsPage() { if (!schedules || schedules.length === 0) { return ( - - -
-
-
-
-
-
- -
-
-

No backup job created

-

- Backup jobs allow you to create automated backup schedules for your volumes. Set up your first backup job - to ensure your data is securely backed up. -

- -
- - + + + + Create a backup job + + + } + /> ); } diff --git a/apps/client/app/modules/backups/routes/create-backup.tsx b/apps/client/app/modules/backups/routes/create-backup.tsx new file mode 100644 index 0000000..4ba696c --- /dev/null +++ b/apps/client/app/modules/backups/routes/create-backup.tsx @@ -0,0 +1,177 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Database, HardDrive } from "lucide-react"; +import { Link, useNavigate } from "react-router"; +import { toast } from "sonner"; +import { + createBackupScheduleMutation, + listRepositoriesOptions, + listVolumesOptions, +} from "~/api-client/@tanstack/react-query.gen"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent } from "~/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; +import { parseError } from "~/lib/errors"; +import { EmptyState } from "~/components/empty-state"; +import { getCronExpression } from "~/utils/utils"; +import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form"; +import type { Route } from "./+types/create-backup"; +import { listRepositories, listVolumes } from "~/api-client"; + +export function meta(_: Route.MetaArgs) { + return [ + { title: "Ironmount" }, + { + name: "description", + content: "Create, manage, monitor, and automate your Docker volumes with ease.", + }, + ]; +} + +export const clientLoader = async () => { + const volumes = await listVolumes(); + const repositories = await listRepositories(); + + if (volumes.data && repositories.data) return { volumes: volumes.data, repositories: repositories.data }; + return { volumes: [], repositories: [] }; +}; + +export default function CreateBackup({ loaderData }: Route.ComponentProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [selectedVolumeId, setSelectedVolumeId] = useState(); + + const { data: volumesData, isLoading: loadingVolumes } = useQuery({ + ...listVolumesOptions(), + initialData: loaderData.volumes, + }); + + const { data: repositoriesData } = useQuery({ + ...listRepositoriesOptions(), + initialData: loaderData.repositories, + }); + + const createSchedule = useMutation({ + ...createBackupScheduleMutation(), + onSuccess: (data) => { + toast.success("Backup job created successfully"); + queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); + navigate(`/backups/${data.id}`); + }, + onError: (error) => { + toast.error("Failed to create backup job", { + description: parseError(error)?.message, + }); + }, + }); + + const handleSubmit = (formValues: BackupScheduleFormValues) => { + if (!selectedVolumeId) return; + + 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; + + createSchedule.mutate({ + body: { + volumeId: selectedVolumeId, + repositoryId: formValues.repositoryId, + enabled: true, + cronExpression, + retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + }, + }); + }; + + const selectedVolume = volumesData.find((v) => v.id === selectedVolumeId); + + if (loadingVolumes) { + return ( +
+

Loading...

+
+ ); + } + + if (!volumesData.length) { + return ( + + Go to volumes + + } + /> + ); + } + + if (!repositoriesData?.length) { + return ( + + Go to repositories + + } + /> + ); + } + + return ( +
+ + + + + + {selectedVolume ? ( + + ) : ( + + +
+
+
+
+
+
+ +
+
+

Select a volume

+

+ Choose a volume from the dropdown above to configure its backup schedule. +

+
+ + + )} +
+ ); +} diff --git a/apps/client/app/modules/details/tabs/backups.tsx b/apps/client/app/modules/details/tabs/backups.tsx deleted file mode 100644 index 7849a25..0000000 --- a/apps/client/app/modules/details/tabs/backups.tsx +++ /dev/null @@ -1,199 +0,0 @@ -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 } from "~/components/ui/card"; -import type { Volume } from "~/lib/types"; -import { - listRepositoriesOptions, - upsertBackupScheduleMutation, - getBackupScheduleForVolumeOptions, - runBackupNowMutation, -} 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; -}; - -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 queryClient = useQueryClient(); - const [isEditMode, setIsEditMode] = useState(false); - - const { data: repositoriesData, isLoading: loadingRepositories } = useQuery({ - ...listRepositoriesOptions(), - }); - - const { data: existingSchedule, isLoading: loadingSchedules } = useQuery({ - ...getBackupScheduleForVolumeOptions({ path: { volumeId: volume.id.toString() } }), - }); - - const repositories = repositoriesData || []; - - const upsertSchedule = useMutation({ - ...upsertBackupScheduleMutation(), - onSuccess: () => { - toast.success("Backup schedule saved successfully"); - queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); - queryClient.invalidateQueries({ queryKey: ["getBackupScheduleForVolume", volume.id.toString()] }); - }, - onError: (error) => { - toast.error("Failed to save backup schedule", { - description: parseError(error)?.message, - }); - }, - }); - - const runBackupNow = useMutation({ - ...runBackupNowMutation(), - onSuccess: () => { - toast.success("Backup started successfully"); - queryClient.invalidateQueries({ queryKey: ["getBackupScheduleForVolume", volume.id.toString()] }); - }, - onError: (error) => { - toast.error("Failed to start backup", { - 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; - - upsertSchedule.mutate({ - body: { - volumeId: volume.id, - repositoryId: formValues.repositoryId, - enabled: existingSchedule?.enabled ?? true, - cronExpression, - retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, - }, - }); - - if (existingSchedule) { - setIsEditMode(false); - } - }; - - 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; - - upsertSchedule.mutate({ - body: { - volumeId: existingSchedule.volumeId, - repositoryId: existingSchedule.repositoryId, - enabled, - cronExpression: existingSchedule.cronExpression, - retentionPolicy: existingSchedule.retentionPolicy || undefined, - }, - }); - }; - - const handleRunBackupNow = () => { - if (!existingSchedule) return; - - runBackupNow.mutate({ - path: { - scheduleId: existingSchedule.id.toString(), - }, - }); - }; - - const repository = repositories.find((repo) => repo.id === existingSchedule?.repositoryId); - - if (existingSchedule && repository && !isEditMode) { - return ( - - ); - } - - return ( -
- {existingSchedule && isEditMode && ( -
- -
- )} - -
- ); -}; diff --git a/apps/client/app/modules/repositories/routes/repositories.tsx b/apps/client/app/modules/repositories/routes/repositories.tsx index b800312..0c844b2 100644 --- a/apps/client/app/modules/repositories/routes/repositories.tsx +++ b/apps/client/app/modules/repositories/routes/repositories.tsx @@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import type { Route } from "./+types/repositories"; import { cn } from "~/lib/utils"; +import { EmptyState } from "~/components/empty-state"; export function meta(_: Route.MetaArgs) { return [ @@ -64,9 +65,12 @@ export default function Repositories({ loaderData }: Route.ComponentProps) { if (hasNoRepositories) { return ( - - - + } + /> ); } @@ -183,30 +187,3 @@ export default function Repositories({ loaderData }: Route.ComponentProps) { ); } - -function RepositoriesEmptyState() { - const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false); - - return ( -
-
-
-
-
- -
- -
-
-
-

No repositories yet

-

- Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized - for storage efficiency. -

-
- - -
- ); -} diff --git a/apps/client/app/modules/details/components/healthchecks-card.tsx b/apps/client/app/modules/volumes/components/healthchecks-card.tsx similarity index 100% rename from apps/client/app/modules/details/components/healthchecks-card.tsx rename to apps/client/app/modules/volumes/components/healthchecks-card.tsx diff --git a/apps/client/app/modules/details/components/storage-chart.tsx b/apps/client/app/modules/volumes/components/storage-chart.tsx similarity index 100% rename from apps/client/app/modules/details/components/storage-chart.tsx rename to apps/client/app/modules/volumes/components/storage-chart.tsx diff --git a/apps/client/app/routes/details.tsx b/apps/client/app/modules/volumes/routes/volume-details.tsx similarity index 87% rename from apps/client/app/routes/details.tsx rename to apps/client/app/modules/volumes/routes/volume-details.tsx index 31a5342..8905fca 100644 --- a/apps/client/app/routes/details.tsx +++ b/apps/client/app/modules/volumes/routes/volume-details.tsx @@ -13,12 +13,11 @@ 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 { FilesTabContent } from "~/modules/details/tabs/files"; -import { VolumeInfoTabContent } from "~/modules/details/tabs/info"; -import { getVolume } from "../api-client"; -import type { Route } from "./+types/details"; +import type { Route } from "./+types/volume-details"; +import { getVolume } from "~/api-client"; +import { VolumeInfoTabContent } from "../tabs/info"; +import { FilesTabContent } from "../tabs/files"; +import { DockerTabContent } from "../tabs/docker"; export function meta({ params }: Route.MetaArgs) { return [ @@ -35,7 +34,7 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { if (volume.data) return volume.data; }; -export default function DetailsPage({ loaderData }: Route.ComponentProps) { +export default function VolumeDetails({ loaderData }: Route.ComponentProps) { const { name } = useParams<{ name: string }>(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -138,7 +137,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) { Configuration Files Docker - Backups @@ -149,9 +147,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) { - - - ); diff --git a/apps/client/app/routes/home.tsx b/apps/client/app/modules/volumes/routes/volumes.tsx similarity index 82% rename from apps/client/app/routes/home.tsx rename to apps/client/app/modules/volumes/routes/volumes.tsx index a8460de..7c9599c 100644 --- a/apps/client/app/routes/home.tsx +++ b/apps/client/app/modules/volumes/routes/volumes.tsx @@ -1,8 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import { Copy, RotateCcw } from "lucide-react"; +import { HardDrive, RotateCcw } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router"; -import { toast } from "sonner"; import { listVolumes } from "~/api-client"; import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen"; import { CreateVolumeDialog } from "~/components/create-volume-dialog"; @@ -14,8 +13,7 @@ import { Input } from "~/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { VolumeIcon } from "~/components/volume-icon"; -import { copyToClipboard } from "~/utils/clipboard"; -import type { Route } from "./+types/home"; +import type { Route } from "./+types/volumes"; export function meta(_: Route.MetaArgs) { return [ @@ -29,11 +27,11 @@ export function meta(_: Route.MetaArgs) { export const clientLoader = async () => { const volumes = await listVolumes(); - if (volumes.data) return { volumes: volumes.data.volumes }; - return { volumes: [] }; + if (volumes.data) return volumes.data; + return []; }; -export default function Home({ loaderData }: Route.ComponentProps) { +export default function Volumes({ loaderData }: Route.ComponentProps) { const [createVolumeOpen, setCreateVolumeOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState(""); @@ -55,21 +53,24 @@ export default function Home({ loaderData }: Route.ComponentProps) { }); const filteredVolumes = - data?.volumes.filter((volume) => { + data.filter((volume) => { const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase()); const matchesStatus = !statusFilter || volume.status === statusFilter; const matchesBackend = !backendFilter || volume.type === backendFilter; return matchesSearch && matchesStatus && matchesBackend; }) || []; - const hasNoVolumes = data?.volumes.length === 0; + const hasNoVolumes = data.length === 0; const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes; if (hasNoVolumes) { return ( - - - + } + /> ); } @@ -118,7 +119,6 @@ export default function Home({ loaderData }: Route.ComponentProps) { Name Backend - Mountpoint Status @@ -146,22 +146,6 @@ export default function Home({ loaderData }: Route.ComponentProps) { - - - diff --git a/apps/client/app/modules/details/tabs/docker.tsx b/apps/client/app/modules/volumes/tabs/docker.tsx similarity index 100% rename from apps/client/app/modules/details/tabs/docker.tsx rename to apps/client/app/modules/volumes/tabs/docker.tsx diff --git a/apps/client/app/modules/details/tabs/files.tsx b/apps/client/app/modules/volumes/tabs/files.tsx similarity index 100% rename from apps/client/app/modules/details/tabs/files.tsx rename to apps/client/app/modules/volumes/tabs/files.tsx diff --git a/apps/client/app/modules/details/tabs/info.tsx b/apps/client/app/modules/volumes/tabs/info.tsx similarity index 100% rename from apps/client/app/modules/details/tabs/info.tsx rename to apps/client/app/modules/volumes/tabs/info.tsx diff --git a/apps/client/app/routes.ts b/apps/client/app/routes.ts index 9440fdd..342e54b 100644 --- a/apps/client/app/routes.ts +++ b/apps/client/app/routes.ts @@ -1,14 +1,15 @@ import { layout, type RouteConfig, route } from "@react-router/dev/routes"; export default [ - route("onboarding", "./routes/onboarding.tsx"), - route("login", "./routes/login.tsx"), + route("onboarding", "./modules/auth/routes/onboarding.tsx"), + route("login", "./modules/auth/routes/login.tsx"), layout("./components/layout.tsx", [ route("/", "./routes/root.tsx"), - route("volumes", "./routes/home.tsx"), - route("volumes/:name", "./routes/details.tsx"), - route("backup-jobs", "./routes/backup-jobs.tsx"), - route("backup-jobs/:scheduleId", "./routes/schedule-details.tsx"), + route("volumes", "./modules/volumes/routes/volumes.tsx"), + route("volumes/:name", "./modules/volumes/routes/volume-details.tsx"), + route("backups", "./modules/backups/routes/backups.tsx"), + route("backups/create", "./modules/backups/routes/create-backup.tsx"), + route("backups/:id", "./modules/backups/routes/backup-details.tsx"), route("repositories", "./modules/repositories/routes/repositories.tsx"), route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"), route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"), diff --git a/apps/client/app/utils/utils.ts b/apps/client/app/utils/utils.ts new file mode 100644 index 0000000..a3a5736 --- /dev/null +++ b/apps/client/app/utils/utils.ts @@ -0,0 +1,17 @@ +export 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"}`; +}; diff --git a/apps/server/src/core/constants.ts b/apps/server/src/core/constants.ts index 91fae49..0f41c4c 100644 --- a/apps/server/src/core/constants.ts +++ b/apps/server/src/core/constants.ts @@ -1,4 +1,5 @@ export const OPERATION_TIMEOUT = 5000; -export const VOLUME_MOUNT_BASE = "/volumes"; +export const VOLUME_MOUNT_BASE = "/data/volumes"; +export const REPOSITORY_BASE = "/data/repositories"; export const DATABASE_URL = "/data/ironmount.db"; export const RESTIC_PASS_FILE = "/data/secrets/restic.pass"; diff --git a/apps/server/src/db/db.ts b/apps/server/src/db/db.ts index e9f3971..555bec2 100644 --- a/apps/server/src/db/db.ts +++ b/apps/server/src/db/db.ts @@ -7,6 +7,7 @@ import { DATABASE_URL } from "../core/constants"; import * as schema from "./schema"; const sqlite = new Database(DATABASE_URL); +sqlite.run("PRAGMA foreign_keys = ON;"); export const db = drizzle({ client: sqlite, schema }); diff --git a/apps/server/src/modules/backups/backups.dto.ts b/apps/server/src/modules/backups/backups.dto.ts index a7caca3..447c1b8 100644 --- a/apps/server/src/modules/backups/backups.dto.ts +++ b/apps/server/src/modules/backups/backups.dto.ts @@ -30,9 +30,12 @@ const backupScheduleSchema = type({ nextBackupAt: "number | null", createdAt: "number", updatedAt: "number", -}); - -export type BackupScheduleDto = typeof backupScheduleSchema.infer; +}).and( + type({ + volume: volumeSchema, + repository: repositorySchema, + }), +); /** * List all backup schedules @@ -60,12 +63,7 @@ export const listBackupSchedulesDto = describeRoute({ /** * Get a single backup schedule */ -export const getBackupScheduleResponse = backupScheduleSchema.and( - type({ - volume: volumeSchema, - repository: repositorySchema, - }), -); +export const getBackupScheduleResponse = backupScheduleSchema; export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer; @@ -121,7 +119,7 @@ export const createBackupScheduleBody = type({ export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer; -export const createBackupScheduleResponse = backupScheduleSchema; +export const createBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository"); export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer; @@ -156,7 +154,7 @@ export const updateBackupScheduleBody = type({ export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer; -export const updateBackupScheduleResponse = backupScheduleSchema; +export const updateBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository"); export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer; @@ -192,7 +190,7 @@ export const upsertBackupScheduleBody = type({ export type UpsertBackupScheduleBody = typeof upsertBackupScheduleBody.infer; -export const upsertBackupScheduleResponse = backupScheduleSchema; +export const upsertBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository"); export type UpsertBackupScheduleDto = typeof upsertBackupScheduleResponse.infer; diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts index 907cbb3..ae6d7e0 100644 --- a/apps/server/src/modules/backups/backups.service.ts +++ b/apps/server/src/modules/backups/backups.service.ts @@ -27,7 +27,12 @@ const calculateNextRun = (cronExpression: string): number => { }; const listSchedules = async () => { - const schedules = await db.query.backupSchedulesTable.findMany({}); + const schedules = await db.query.backupSchedulesTable.findMany({ + with: { + volume: true, + repository: true, + }, + }); return schedules; }; @@ -259,6 +264,7 @@ const getSchedulesToExecute = async () => { const getScheduleForVolume = async (volumeId: number) => { const schedule = await db.query.backupSchedulesTable.findFirst({ where: eq(backupSchedulesTable.volumeId, volumeId), + with: { volume: true, repository: true }, }); return schedule ?? null; diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index edeeb59..28482b7 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -29,14 +29,7 @@ export const volumeController = new Hono() .get("/", listVolumesDto, async (c) => { const volumes = await volumeService.listVolumes(); - const response = { - volumes: volumes.map((volume) => ({ - path: getVolumePath(volume.name), - ...volume, - })), - }; - - return c.json(response, 200); + return c.json(volumes, 200); }) .post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => { const body = c.req.valid("json"); diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index bb345f9..6c9f2c6 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -5,7 +5,6 @@ import { describeRoute, resolver } from "hono-openapi"; export const volumeSchema = type({ id: "number", name: "string", - path: "string", type: type.valueOf(BACKEND_TYPES), status: type.valueOf(BACKEND_STATUS), lastError: "string | null", @@ -21,9 +20,7 @@ export type VolumeDto = typeof volumeSchema.infer; /** * List all volumes */ -export const listVolumesResponse = type({ - volumes: volumeSchema.array(), -}); +export const listVolumesResponse = volumeSchema.array(); export type ListVolumesDto = typeof listVolumesResponse.infer; export const listVolumesDto = describeRoute({ diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 5a0c7cd..91982f0 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -4,7 +4,7 @@ import path from "node:path"; import type { RepositoryConfig } from "@ironmount/schemas/restic"; import { type } from "arktype"; import { $ } from "bun"; -import { RESTIC_PASS_FILE } from "../core/constants"; +import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants"; import { logger } from "./logger"; import { cryptoUtils } from "./crypto"; import type { RetentionPolicy } from "../modules/backups/backups.dto"; @@ -69,7 +69,7 @@ const ensurePassfile = async () => { const buildRepoUrl = (config: RepositoryConfig): string => { switch (config.backend) { case "local": - return `/repositories/${config.name}`; + return `${REPOSITORY_BASE}/${config.name}`; case "s3": return `s3:${config.endpoint}/${config.bucket}`; default: { diff --git a/docker-compose.yml b/docker-compose.yml index 5e8734c..9913599 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,6 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - /run/docker/plugins:/run/docker/plugins - - ./data/volumes/:/volumes - - ./data/repositories/:/repositories # - /proc:/host/proc:ro - ./data:/data