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,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;
|
||||
};
|
||||
|
||||
@@ -26,8 +26,8 @@ const items = [
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
title: "Backup jobs",
|
||||
url: "/backup-jobs",
|
||||
title: "Backups",
|
||||
url: "/backups",
|
||||
icon: CalendarClock,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }>();
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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"),
|
||||
|
||||
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 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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user