mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
refactor: frontend components consolidation
This commit is contained in:
@@ -136,8 +136,7 @@ export type ListVolumesResponses = {
|
|||||||
/**
|
/**
|
||||||
* A list of volumes
|
* A list of volumes
|
||||||
*/
|
*/
|
||||||
200: {
|
200: Array<{
|
||||||
volumes: Array<{
|
|
||||||
autoRemount: boolean;
|
autoRemount: boolean;
|
||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
@@ -174,13 +173,11 @@ export type ListVolumesResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
|
||||||
status: "error" | "mounted" | "unmounted";
|
status: "error" | "mounted" | "unmounted";
|
||||||
type: "directory" | "nfs" | "smb" | "webdav";
|
type: "directory" | "nfs" | "smb" | "webdav";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
export type ListVolumesResponse = ListVolumesResponses[keyof ListVolumesResponses];
|
export type ListVolumesResponse = ListVolumesResponses[keyof ListVolumesResponses];
|
||||||
|
|
||||||
@@ -264,7 +261,6 @@ export type CreateVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
|
||||||
status: "error" | "mounted" | "unmounted";
|
status: "error" | "mounted" | "unmounted";
|
||||||
type: "directory" | "nfs" | "smb" | "webdav";
|
type: "directory" | "nfs" | "smb" | "webdav";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -406,7 +402,6 @@ export type GetVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
|
||||||
status: "error" | "mounted" | "unmounted";
|
status: "error" | "mounted" | "unmounted";
|
||||||
type: "directory" | "nfs" | "smb" | "webdav";
|
type: "directory" | "nfs" | "smb" | "webdav";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -505,7 +500,6 @@ export type UpdateVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
|
||||||
status: "error" | "mounted" | "unmounted";
|
status: "error" | "mounted" | "unmounted";
|
||||||
type: "directory" | "nfs" | "smb" | "webdav";
|
type: "directory" | "nfs" | "smb" | "webdav";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -900,6 +894,29 @@ export type ListBackupSchedulesResponses = {
|
|||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: "error" | "success" | null;
|
lastBackupStatus: "error" | "success" | null;
|
||||||
nextBackupAt: number | 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;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
keepDaily?: number;
|
keepDaily?: number;
|
||||||
@@ -911,6 +928,47 @@ export type ListBackupSchedulesResponses = {
|
|||||||
keepYearly?: number;
|
keepYearly?: number;
|
||||||
} | null;
|
} | null;
|
||||||
updatedAt: number;
|
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;
|
volumeId: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -1088,7 +1146,6 @@ export type GetBackupScheduleResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
|
||||||
status: "error" | "mounted" | "unmounted";
|
status: "error" | "mounted" | "unmounted";
|
||||||
type: "directory" | "nfs" | "smb" | "webdav";
|
type: "directory" | "nfs" | "smb" | "webdav";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -1180,6 +1237,29 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: "error" | "success" | null;
|
lastBackupStatus: "error" | "success" | null;
|
||||||
nextBackupAt: number | 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;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
keepDaily?: number;
|
keepDaily?: number;
|
||||||
@@ -1191,6 +1271,47 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
keepYearly?: number;
|
keepYearly?: number;
|
||||||
} | null;
|
} | null;
|
||||||
updatedAt: number;
|
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;
|
volumeId: number;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const items = [
|
|||||||
icon: Database,
|
icon: Database,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Backup jobs",
|
title: "Backups",
|
||||||
url: "/backup-jobs",
|
url: "/backups",
|
||||||
icon: CalendarClock,
|
icon: CalendarClock,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Database } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
|
import { createRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
@@ -34,7 +34,7 @@ export const CreateRepositoryDialog = ({ open, setOpen }: Props) => {
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
<Database size={16} className="mr-2" />
|
<Plus size={16} className="mr-2" />
|
||||||
Create Repository
|
Create Repository
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|||||||
@@ -1,56 +1,32 @@
|
|||||||
import { Database, HardDrive, HeartPulse, Plus } from "lucide-react";
|
import { Card } from "./ui/card";
|
||||||
import { CreateVolumeDialog } from "./create-volume-dialog";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export function EmptyState() {
|
type EmptyStateProps = {
|
||||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
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 (
|
return (
|
||||||
|
<Card className="p-0 gap-0">
|
||||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||||
<div className="relative mb-8">
|
<div className="relative mb-8">
|
||||||
<div className="absolute inset-0 animate-pulse">
|
<div className="absolute inset-0 animate-pulse">
|
||||||
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
|
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
|
||||||
</div>
|
</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">
|
<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} />
|
<Cicon className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-md space-y-3 mb-8">
|
<div className="max-w-md space-y-3 mb-8">
|
||||||
<h3 className="text-2xl font-semibold text-foreground">No volumes yet</h3>
|
<h3 className="text-2xl font-semibold text-foreground">{title}</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
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" />
|
|
||||||
</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>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
{button}
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,26 +10,9 @@ import {
|
|||||||
runBackupNowMutation,
|
runBackupNowMutation,
|
||||||
} from "~/api-client/@tanstack/react-query.gen";
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/lib/errors";
|
import { parseError } from "~/lib/errors";
|
||||||
import { CreateScheduleForm, type BackupScheduleFormValues } from "~/modules/details/components/create-schedule-form";
|
import { getCronExpression } from "~/utils/utils";
|
||||||
import { ScheduleSummary } from "~/modules/details/components/schedule-summary";
|
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
||||||
|
import { ScheduleSummary } from "../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"}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ScheduleDetailsPage() {
|
export default function ScheduleDetailsPage() {
|
||||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||||
@@ -1,14 +1,34 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
|
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { listBackupSchedules } from "~/api-client";
|
||||||
import { listBackupSchedulesOptions } from "~/api-client/@tanstack/react-query.gen";
|
import { listBackupSchedulesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
|
import { EmptyState } from "~/components/empty-state";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
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({
|
const { data: schedules, isLoading } = useQuery({
|
||||||
...listBackupSchedulesOptions(),
|
...listBackupSchedulesOptions(),
|
||||||
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
@@ -23,31 +43,19 @@ export default function BackupJobsPage() {
|
|||||||
|
|
||||||
if (!schedules || schedules.length === 0) {
|
if (!schedules || schedules.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<EmptyState
|
||||||
<CardContent className="py-16">
|
icon={CalendarClock}
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
title="No backup job"
|
||||||
<div className="relative mb-6">
|
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."
|
||||||
<div className="absolute inset-0 animate-pulse">
|
button={
|
||||||
<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>
|
<Button>
|
||||||
<Link to="/repositories" className="flex items-center">
|
<Link to="/backups/create" className="flex items-center">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create a backup job
|
Create a backup job
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
177
apps/client/app/modules/backups/routes/create-backup.tsx
Normal file
177
apps/client/app/modules/backups/routes/create-backup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||||
import type { Route } from "./+types/repositories";
|
import type { Route } from "./+types/repositories";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { EmptyState } from "~/components/empty-state";
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
@@ -64,9 +65,12 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
if (hasNoRepositories) {
|
if (hasNoRepositories) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-0 gap-0">
|
<EmptyState
|
||||||
<RepositoriesEmptyState />
|
icon={Database}
|
||||||
</Card>
|
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>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|||||||
import { VolumeIcon } from "~/components/volume-icon";
|
import { VolumeIcon } from "~/components/volume-icon";
|
||||||
import { parseError } from "~/lib/errors";
|
import { parseError } from "~/lib/errors";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups";
|
import type { Route } from "./+types/volume-details";
|
||||||
import { DockerTabContent } from "~/modules/details/tabs/docker";
|
import { getVolume } from "~/api-client";
|
||||||
import { FilesTabContent } from "~/modules/details/tabs/files";
|
import { VolumeInfoTabContent } from "../tabs/info";
|
||||||
import { VolumeInfoTabContent } from "~/modules/details/tabs/info";
|
import { FilesTabContent } from "../tabs/files";
|
||||||
import { getVolume } from "../api-client";
|
import { DockerTabContent } from "../tabs/docker";
|
||||||
import type { Route } from "./+types/details";
|
|
||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
@@ -35,7 +34,7 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
|||||||
if (volume.data) return volume.data;
|
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 { name } = useParams<{ name: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -138,7 +137,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||||
<TabsTrigger value="files">Files</TabsTrigger>
|
<TabsTrigger value="files">Files</TabsTrigger>
|
||||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="info">
|
<TabsContent value="info">
|
||||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||||
@@ -149,9 +147,6 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
<TabsContent value="docker">
|
<TabsContent value="docker">
|
||||||
<DockerTabContent volume={volume} />
|
<DockerTabContent volume={volume} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
|
||||||
<VolumeBackupsTabContent volume={volume} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Copy, RotateCcw } from "lucide-react";
|
import { HardDrive, RotateCcw } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { listVolumes } from "~/api-client";
|
import { listVolumes } from "~/api-client";
|
||||||
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
|
import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateVolumeDialog } from "~/components/create-volume-dialog";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||||
import { VolumeIcon } from "~/components/volume-icon";
|
import { VolumeIcon } from "~/components/volume-icon";
|
||||||
import { copyToClipboard } from "~/utils/clipboard";
|
import type { Route } from "./+types/volumes";
|
||||||
import type { Route } from "./+types/home";
|
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
@@ -29,11 +27,11 @@ export function meta(_: Route.MetaArgs) {
|
|||||||
|
|
||||||
export const clientLoader = async () => {
|
export const clientLoader = async () => {
|
||||||
const volumes = await listVolumes();
|
const volumes = await listVolumes();
|
||||||
if (volumes.data) return { volumes: volumes.data.volumes };
|
if (volumes.data) return volumes.data;
|
||||||
return { volumes: [] };
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
@@ -55,21 +53,24 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredVolumes =
|
const filteredVolumes =
|
||||||
data?.volumes.filter((volume) => {
|
data.filter((volume) => {
|
||||||
const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase());
|
const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const matchesStatus = !statusFilter || volume.status === statusFilter;
|
const matchesStatus = !statusFilter || volume.status === statusFilter;
|
||||||
const matchesBackend = !backendFilter || volume.type === backendFilter;
|
const matchesBackend = !backendFilter || volume.type === backendFilter;
|
||||||
return matchesSearch && matchesStatus && matchesBackend;
|
return matchesSearch && matchesStatus && matchesBackend;
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
const hasNoVolumes = data?.volumes.length === 0;
|
const hasNoVolumes = data.length === 0;
|
||||||
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
||||||
|
|
||||||
if (hasNoVolumes) {
|
if (hasNoVolumes) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-0 gap-0">
|
<EmptyState
|
||||||
<EmptyState />
|
icon={HardDrive}
|
||||||
</Card>
|
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>
|
<TableRow>
|
||||||
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||||
<TableHead className="uppercase hidden sm:table-cell">Mountpoint</TableHead>
|
|
||||||
<TableHead className="uppercase text-center">Status</TableHead>
|
<TableHead className="uppercase text-center">Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -146,22 +146,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<VolumeIcon backend={volume.type} />
|
<VolumeIcon backend={volume.type} />
|
||||||
</TableCell>
|
</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">
|
<TableCell className="text-center">
|
||||||
<StatusDot status={volume.status} />
|
<StatusDot status={volume.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { layout, type RouteConfig, route } from "@react-router/dev/routes";
|
import { layout, type RouteConfig, route } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
route("onboarding", "./routes/onboarding.tsx"),
|
route("onboarding", "./modules/auth/routes/onboarding.tsx"),
|
||||||
route("login", "./routes/login.tsx"),
|
route("login", "./modules/auth/routes/login.tsx"),
|
||||||
layout("./components/layout.tsx", [
|
layout("./components/layout.tsx", [
|
||||||
route("/", "./routes/root.tsx"),
|
route("/", "./routes/root.tsx"),
|
||||||
route("volumes", "./routes/home.tsx"),
|
route("volumes", "./modules/volumes/routes/volumes.tsx"),
|
||||||
route("volumes/:name", "./routes/details.tsx"),
|
route("volumes/:name", "./modules/volumes/routes/volume-details.tsx"),
|
||||||
route("backup-jobs", "./routes/backup-jobs.tsx"),
|
route("backups", "./modules/backups/routes/backups.tsx"),
|
||||||
route("backup-jobs/:scheduleId", "./routes/schedule-details.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", "./modules/repositories/routes/repositories.tsx"),
|
||||||
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
|
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
|
||||||
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
|
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
|
||||||
|
|||||||
17
apps/client/app/utils/utils.ts
Normal file
17
apps/client/app/utils/utils.ts
Normal 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"}`;
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const OPERATION_TIMEOUT = 5000;
|
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 DATABASE_URL = "/data/ironmount.db";
|
||||||
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";
|
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DATABASE_URL } from "../core/constants";
|
|||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|
||||||
const sqlite = new Database(DATABASE_URL);
|
const sqlite = new Database(DATABASE_URL);
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
export const db = drizzle({ client: sqlite, schema });
|
export const db = drizzle({ client: sqlite, schema });
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ const backupScheduleSchema = type({
|
|||||||
nextBackupAt: "number | null",
|
nextBackupAt: "number | null",
|
||||||
createdAt: "number",
|
createdAt: "number",
|
||||||
updatedAt: "number",
|
updatedAt: "number",
|
||||||
});
|
}).and(
|
||||||
|
type({
|
||||||
export type BackupScheduleDto = typeof backupScheduleSchema.infer;
|
volume: volumeSchema,
|
||||||
|
repository: repositorySchema,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backup schedules
|
* List all backup schedules
|
||||||
@@ -60,12 +63,7 @@ export const listBackupSchedulesDto = describeRoute({
|
|||||||
/**
|
/**
|
||||||
* Get a single backup schedule
|
* Get a single backup schedule
|
||||||
*/
|
*/
|
||||||
export const getBackupScheduleResponse = backupScheduleSchema.and(
|
export const getBackupScheduleResponse = backupScheduleSchema;
|
||||||
type({
|
|
||||||
volume: volumeSchema,
|
|
||||||
repository: repositorySchema,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
|
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
|
||||||
|
|
||||||
@@ -121,7 +119,7 @@ export const createBackupScheduleBody = type({
|
|||||||
|
|
||||||
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
|
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
|
||||||
|
|
||||||
export const createBackupScheduleResponse = backupScheduleSchema;
|
export const createBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
|
||||||
|
|
||||||
export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer;
|
export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer;
|
||||||
|
|
||||||
@@ -156,7 +154,7 @@ export const updateBackupScheduleBody = type({
|
|||||||
|
|
||||||
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
|
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
|
||||||
|
|
||||||
export const updateBackupScheduleResponse = backupScheduleSchema;
|
export const updateBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
|
||||||
|
|
||||||
export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer;
|
export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer;
|
||||||
|
|
||||||
@@ -192,7 +190,7 @@ export const upsertBackupScheduleBody = type({
|
|||||||
|
|
||||||
export type UpsertBackupScheduleBody = typeof upsertBackupScheduleBody.infer;
|
export type UpsertBackupScheduleBody = typeof upsertBackupScheduleBody.infer;
|
||||||
|
|
||||||
export const upsertBackupScheduleResponse = backupScheduleSchema;
|
export const upsertBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
|
||||||
|
|
||||||
export type UpsertBackupScheduleDto = typeof upsertBackupScheduleResponse.infer;
|
export type UpsertBackupScheduleDto = typeof upsertBackupScheduleResponse.infer;
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ const calculateNextRun = (cronExpression: string): number => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const listSchedules = async () => {
|
const listSchedules = async () => {
|
||||||
const schedules = await db.query.backupSchedulesTable.findMany({});
|
const schedules = await db.query.backupSchedulesTable.findMany({
|
||||||
|
with: {
|
||||||
|
volume: true,
|
||||||
|
repository: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
return schedules;
|
return schedules;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,6 +264,7 @@ const getSchedulesToExecute = async () => {
|
|||||||
const getScheduleForVolume = async (volumeId: number) => {
|
const getScheduleForVolume = async (volumeId: number) => {
|
||||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
where: eq(backupSchedulesTable.volumeId, volumeId),
|
where: eq(backupSchedulesTable.volumeId, volumeId),
|
||||||
|
with: { volume: true, repository: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return schedule ?? null;
|
return schedule ?? null;
|
||||||
|
|||||||
@@ -29,14 +29,7 @@ export const volumeController = new Hono()
|
|||||||
.get("/", listVolumesDto, async (c) => {
|
.get("/", listVolumesDto, async (c) => {
|
||||||
const volumes = await volumeService.listVolumes();
|
const volumes = await volumeService.listVolumes();
|
||||||
|
|
||||||
const response = {
|
return c.json<ListVolumesDto>(volumes, 200);
|
||||||
volumes: volumes.map((volume) => ({
|
|
||||||
path: getVolumePath(volume.name),
|
|
||||||
...volume,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
return c.json<ListVolumesDto>(response, 200);
|
|
||||||
})
|
})
|
||||||
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
|
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { describeRoute, resolver } from "hono-openapi";
|
|||||||
export const volumeSchema = type({
|
export const volumeSchema = type({
|
||||||
id: "number",
|
id: "number",
|
||||||
name: "string",
|
name: "string",
|
||||||
path: "string",
|
|
||||||
type: type.valueOf(BACKEND_TYPES),
|
type: type.valueOf(BACKEND_TYPES),
|
||||||
status: type.valueOf(BACKEND_STATUS),
|
status: type.valueOf(BACKEND_STATUS),
|
||||||
lastError: "string | null",
|
lastError: "string | null",
|
||||||
@@ -21,9 +20,7 @@ export type VolumeDto = typeof volumeSchema.infer;
|
|||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
export const listVolumesResponse = type({
|
export const listVolumesResponse = volumeSchema.array();
|
||||||
volumes: volumeSchema.array(),
|
|
||||||
});
|
|
||||||
export type ListVolumesDto = typeof listVolumesResponse.infer;
|
export type ListVolumesDto = typeof listVolumesResponse.infer;
|
||||||
|
|
||||||
export const listVolumesDto = describeRoute({
|
export const listVolumesDto = describeRoute({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import type { RepositoryConfig } from "@ironmount/schemas/restic";
|
import type { RepositoryConfig } from "@ironmount/schemas/restic";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import { RESTIC_PASS_FILE } from "../core/constants";
|
import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { cryptoUtils } from "./crypto";
|
import { cryptoUtils } from "./crypto";
|
||||||
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
||||||
@@ -69,7 +69,7 @@ const ensurePassfile = async () => {
|
|||||||
const buildRepoUrl = (config: RepositoryConfig): string => {
|
const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "local":
|
case "local":
|
||||||
return `/repositories/${config.name}`;
|
return `${REPOSITORY_BASE}/${config.name}`;
|
||||||
case "s3":
|
case "s3":
|
||||||
return `s3:${config.endpoint}/${config.bucket}`;
|
return `s3:${config.endpoint}/${config.bucket}`;
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- ./data/volumes/:/volumes
|
|
||||||
- ./data/repositories/:/repositories
|
|
||||||
# - /proc:/host/proc:ro
|
# - /proc:/host/proc:ro
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user