refactor: frontend components consolidation

This commit is contained in:
Nicolas Meienberger
2025-11-01 17:49:40 +01:00
parent 18115b374c
commit 3befa127d7
30 changed files with 483 additions and 449 deletions

View File

@@ -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;
};

View File

@@ -26,8 +26,8 @@ const items = [
icon: Database,
},
{
title: "Backup jobs",
url: "/backup-jobs",
title: "Backups",
url: "/backups",
icon: CalendarClock,
},
];

View File

@@ -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) => {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Database size={16} className="mr-2" />
<Plus size={16} className="mr-2" />
Create Repository
</Button>
</DialogTrigger>

View File

@@ -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<React.SVGProps<SVGSVGElement>>;
button?: React.ReactNode;
};
export function EmptyState(props: EmptyStateProps) {
const { title, description, icon: Cicon, button } = props;
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3 mb-8">
<h3 className="text-2xl font-semibold text-foreground">No volumes yet</h3>
<p className="text-muted-foreground">
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.
</p>
</div>
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-0 max-w-3xl">
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Database className="w-5 h-5 text-primary" />
<Card className="p-0 gap-0">
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<h4 className="font-medium text-sm">Multiple Backends</h4>
<p className="text-xs text-muted-foreground">Support for local, NFS, and SMB storage</p>
</div>
<div className="flex flex-col items-center gap-2 p-4 border border-r-0 border-l-0 bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<HardDrive className="w-5 h-5 text-primary" />
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Cicon className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
<h4 className="font-medium text-sm">Auto Mounting</h4>
<p className="text-xs text-muted-foreground">Automatic lifecycle management</p>
</div>
<div className="flex flex-col items-center gap-2 p-4 border bg-card-header">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<HeartPulse className="w-5 h-5 text-primary" />
</div>
<h4 className="font-medium text-sm">Real-time Monitoring</h4>
<p className="text-xs text-muted-foreground">Live status and health checks</p>
<div className="max-w-md space-y-3 mb-8">
<h3 className="text-2xl font-semibold text-foreground">{title}</h3>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
{button}
</div>
</div>
</Card>
);
}

View File

@@ -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 }>();

View File

@@ -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 (
<Card>
<CardContent className="py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="relative mb-6">
<div className="absolute inset-0 animate-pulse">
<div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<CalendarClock className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<h3 className="text-xl font-semibold mb-2">No backup job created</h3>
<p className="text-muted-foreground text-sm mb-6 max-w-md">
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.
</p>
<Button>
<Link to="/repositories" className="flex items-center">
<Plus className="h-4 w-4 mr-2" />
Create a backup job
</Link>
</Button>
</div>
</CardContent>
</Card>
<EmptyState
icon={CalendarClock}
title="No backup job"
description="Backup jobs help you automate the process of backing up your volumes on a regular schedule to ensure your data is safe and secure."
button={
<Button>
<Link to="/backups/create" className="flex items-center">
<Plus className="h-4 w-4 mr-2" />
Create a backup job
</Link>
</Button>
}
/>
);
}

View File

@@ -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<number | undefined>();
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<string, number> = {};
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 (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading...</p>
</div>
);
}
if (!volumesData.length) {
return (
<EmptyState
icon={HardDrive}
title="No volume to backup"
description="To create a backup job, you need to create a volume first. Volumes are the data sources that will be backed up."
button={
<Button>
<Link to="/volumes">Go to volumes</Link>
</Button>
}
/>
);
}
if (!repositoriesData?.length) {
return (
<EmptyState
icon={Database}
title="No repository"
description="To create a backup job, you need to set up a backup repository first. Backup repositories are the destinations where your backups will be stored."
button={
<Button>
<Link to="/repositories">Go to repositories</Link>
</Button>
}
/>
);
}
return (
<div className="container mx-auto space-y-6">
<Card>
<CardContent>
<Select value={selectedVolumeId?.toString()} onValueChange={(v) => setSelectedVolumeId(Number(v))}>
<SelectTrigger id="volume-select">
<SelectValue placeholder="Choose a volume to backup" />
</SelectTrigger>
<SelectContent>
{volumesData.map((volume) => (
<SelectItem key={volume.id} value={volume.id.toString()}>
<span className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
{volume.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedVolume ? (
<CreateScheduleForm volume={selectedVolume} onSubmit={handleSubmit} loading={createSchedule.isPending} />
) : (
<Card>
<CardContent className="py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="relative mb-6">
<div className="absolute inset-0 animate-pulse">
<div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-12 h-12 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<h3 className="text-xl font-semibold mb-2">Select a volume</h3>
<p className="text-muted-foreground text-sm max-w-md">
Choose a volume from the dropdown above to configure its backup schedule.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -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<string, number> = {};
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 (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Loading...</p>
</CardContent>
</Card>
);
}
if (repositories.length === 0) {
return (
<Card>
<CardContent className="py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="relative mb-6">
<div className="absolute inset-0 animate-pulse">
<div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-12 h-12 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<h3 className="text-xl font-semibold mb-2">No repositories available</h3>
<p className="text-muted-foreground text-sm mb-6 max-w-md">
To schedule automated backups, you need to create a repository first. Repositories are secure storage
locations where your backups will be stored.
</p>
<Button>
<Link to="/repositories" className="flex items-center">
<Plus className="h-4 w-4 mr-2" />
Create a repository
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
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 (
<ScheduleSummary
handleToggleEnabled={handleToggleEnabled}
handleRunBackupNow={handleRunBackupNow}
repository={repository}
setIsEditMode={setIsEditMode}
schedule={existingSchedule}
volume={volume}
/>
);
}
return (
<div className="space-y-4">
{existingSchedule && isEditMode && (
<div className="flex justify-end">
<Button variant="outline" onClick={() => setIsEditMode(false)}>
Cancel
</Button>
</div>
)}
<CreateScheduleForm volume={volume} initialValues={existingSchedule ?? undefined} onSubmit={handleSubmit} />
</div>
);
};

View File

@@ -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 (
<Card className="p-0 gap-0">
<RepositoriesEmptyState />
</Card>
<EmptyState
icon={Database}
title="No repository"
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
button={<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />}
/>
);
}
@@ -183,30 +187,3 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
</Card>
);
}
function RepositoriesEmptyState() {
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3 mb-8">
<h3 className="text-2xl font-semibold text-foreground">No repositories yet</h3>
<p className="text-muted-foreground text-sm">
Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized
for storage efficiency.
</p>
</div>
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
</div>
);
}

View File

@@ -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) {
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
</TabsList>
<TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} />
@@ -149,9 +147,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
<TabsContent value="docker">
<DockerTabContent volume={volume} />
</TabsContent>
<TabsContent value="backups">
<VolumeBackupsTabContent volume={volume} />
</TabsContent>
</Tabs>
</>
);

View File

@@ -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 (
<Card className="p-0 gap-0">
<EmptyState />
</Card>
<EmptyState
icon={HardDrive}
title="No volume"
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
/>
);
}
@@ -118,7 +119,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Mountpoint</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
@@ -146,22 +146,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<TableCell>
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="hidden sm:table-cell">
<button
type="button"
className="flex items-center gap-2 cursor-pointer hover:opacity-70 transition-opacity"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(volume.path);
toast.success("Path copied to clipboard");
}}
>
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
{volume.path}
</span>
<Copy size={10} />
</button>
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
</TableCell>

View File

@@ -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"),

View File

@@ -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"}`;
};

View File

@@ -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";

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<ListVolumesDto>(response, 200);
return c.json<ListVolumesDto>(volumes, 200);
})
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
const body = c.req.valid("json");

View File

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

View File

@@ -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: {

View File

@@ -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