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:
@@ -1,112 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { listBackupSchedulesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
export default function BackupJobsPage() {
|
||||
const { data: schedules, isLoading } = useQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">Loading backup schedules...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{schedules.map((schedule) => (
|
||||
<Link key={schedule.id} to={`/backup-jobs/${schedule.id}`}>
|
||||
<Card key={schedule.id} className="flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<CardTitle className="text-lg truncate">Volume #{schedule.volumeId}</CardTitle>
|
||||
</div>
|
||||
<Badge variant={schedule.enabled ? "default" : "secondary"} className="flex-shrink-0">
|
||||
{schedule.enabled ? "Active" : "Paused"}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="flex items-center gap-2 mt-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<span className="truncate">{schedule.repositoryId}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Schedule</span>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last backup</span>
|
||||
<span className="font-medium">
|
||||
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Next backup</span>
|
||||
<span className="font-medium">
|
||||
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
{schedule.lastBackupStatus && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge
|
||||
variant={schedule.lastBackupStatus === "success" ? "default" : "destructive"}
|
||||
className="text-xs"
|
||||
>
|
||||
{schedule.lastBackupStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteVolumeMutation,
|
||||
getVolumeOptions,
|
||||
mountVolumeMutation,
|
||||
unmountVolumeMutation,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
import { StatusDot } from "~/components/status-dot";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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";
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Ironmount - ${params.name}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const volume = await getVolume({ path: { name: params.name ?? "" } });
|
||||
if (volume.data) return volume.data;
|
||||
};
|
||||
|
||||
export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "info";
|
||||
|
||||
const { data } = useQuery({
|
||||
...getVolumeOptions({ path: { name: name ?? "" } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const deleteVol = useMutation({
|
||||
...deleteVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume deleted successfully");
|
||||
navigate("/volumes");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mountVol = useMutation({
|
||||
...mountVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume mounted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to mount volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const unmountVol = useMutation({
|
||||
...unmountVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume unmounted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to unmount volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteConfirm = (name: string) => {
|
||||
if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) {
|
||||
deleteVol.mutate({ path: { name } });
|
||||
}
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return <div>Volume not found</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const { volume, statfs } = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => mountVol.mutate({ path: { name } })}
|
||||
loading={mountVol.isPending}
|
||||
className={cn({ hidden: volume.status === "mounted" })}
|
||||
>
|
||||
Mount
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => unmountVol.mutate({ path: { name } })}
|
||||
loading={unmountVol.isPending}
|
||||
className={cn({ hidden: volume.status !== "mounted" })}
|
||||
>
|
||||
Unmount
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => handleDeleteConfirm(name)} disabled={deleteVol.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||
<TabsList className="mb-2">
|
||||
<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} />
|
||||
</TabsContent>
|
||||
<TabsContent value="files">
|
||||
<FilesTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
<TabsContent value="docker">
|
||||
<DockerTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
<TabsContent value="backups">
|
||||
<VolumeBackupsTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy, 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";
|
||||
import { EmptyState } from "~/components/empty-state";
|
||||
import { StatusDot } from "~/components/status-dot";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
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";
|
||||
|
||||
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();
|
||||
if (volumes.data) return { volumes: volumes.data.volumes };
|
||||
return { volumes: [] };
|
||||
};
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("");
|
||||
setBackendFilter("");
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data } = useQuery({
|
||||
...listVolumesOptions(),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const filteredVolumes =
|
||||
data?.volumes.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 hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
||||
|
||||
if (hasNoVolumes) {
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<EmptyState />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
|
||||
placeholder="Search volumes…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mounted">Mounted</SelectItem>
|
||||
<SelectItem value="unmounted">Unmounted</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<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>
|
||||
<TableBody>
|
||||
{hasNoFilteredVolumes ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No volumes match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredVolumes.map((volume) => (
|
||||
<TableRow
|
||||
key={volume.name}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/volumes/${volume.name}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
|
||||
<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>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
|
||||
{hasNoFilteredVolumes ? (
|
||||
"No volumes match filters."
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
|
||||
{filteredVolumes.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { AuthLayout } from "~/components/auth-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
const loginSchema = type({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=1",
|
||||
});
|
||||
|
||||
type LoginFormValues = typeof loginSchema.inferIn;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: arktypeResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const login = useMutation({
|
||||
...loginMutation(),
|
||||
onSuccess: async () => {
|
||||
navigate("/volumes");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Login failed", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: LoginFormValues) => {
|
||||
login.mutate({
|
||||
body: {
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" placeholder="admin" disabled={login.isPending} autoFocus />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
onClick={() => toast.info("Password reset not implemented")}
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" disabled={login.isPending} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={login.isPending}>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { registerMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { AuthLayout } from "~/components/auth-layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
const onboardingSchema = type({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=8",
|
||||
confirmPassword: "string>=1",
|
||||
});
|
||||
|
||||
type OnboardingFormValues = typeof onboardingSchema.inferIn;
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<OnboardingFormValues>({
|
||||
resolver: arktypeResolver(onboardingSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const registerUser = useMutation({
|
||||
...registerMutation(),
|
||||
onSuccess: async () => {
|
||||
toast.success("Admin user created successfully!");
|
||||
navigate("/volumes");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Failed to create admin user", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: OnboardingFormValues) => {
|
||||
if (values.password !== values.confirmPassword) {
|
||||
form.setError("confirmPassword", {
|
||||
type: "manual",
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
registerUser.mutate({
|
||||
body: {
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Welcome to Ironmount" description="Create the admin user to get started">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
|
||||
</FormControl>
|
||||
<FormDescription>Choose a username for the admin account</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Enter a secure password"
|
||||
disabled={registerUser.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Password must be at least 8 characters long.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Re-enter your password"
|
||||
disabled={registerUser.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={registerUser.isPending}>
|
||||
Create Admin User
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import {
|
||||
upsertBackupScheduleMutation,
|
||||
getBackupScheduleOptions,
|
||||
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"}`;
|
||||
};
|
||||
|
||||
export default function ScheduleDetailsPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
const { data: schedule, isLoading: loadingSchedule } = useQuery({
|
||||
...getBackupScheduleOptions({
|
||||
path: { scheduleId: scheduleId || "" },
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Schedule Details:", schedule);
|
||||
|
||||
const upsertSchedule = useMutation({
|
||||
...upsertBackupScheduleMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup schedule saved successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["getBackupSchedule", scheduleId] });
|
||||
setIsEditMode(false);
|
||||
},
|
||||
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: ["getBackupSchedule", scheduleId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to start backup", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (formValues: BackupScheduleFormValues) => {
|
||||
if (!schedule) 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;
|
||||
|
||||
upsertSchedule.mutate({
|
||||
body: {
|
||||
volumeId: schedule.volumeId,
|
||||
repositoryId: formValues.repositoryId,
|
||||
enabled: schedule.enabled,
|
||||
cronExpression,
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (enabled: boolean) => {
|
||||
if (!schedule) return;
|
||||
|
||||
upsertSchedule.mutate({
|
||||
body: {
|
||||
volumeId: schedule.volumeId,
|
||||
repositoryId: schedule.repositoryId,
|
||||
enabled,
|
||||
cronExpression: schedule.cronExpression,
|
||||
retentionPolicy: schedule.retentionPolicy || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRunBackupNow = () => {
|
||||
if (!schedule) return;
|
||||
|
||||
runBackupNow.mutate({
|
||||
path: {
|
||||
scheduleId: schedule.id.toString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loadingSchedule && !schedule) {
|
||||
return (
|
||||
<div className="container mx-auto p-4 sm:p-8">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<div className="container mx-auto p-4 sm:p-8">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Schedule not found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link to="/backup-jobs">Back to Backup Jobs</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEditMode) {
|
||||
return (
|
||||
<div className="container mx-auto p-4 sm:p-8">
|
||||
<ScheduleSummary
|
||||
handleToggleEnabled={handleToggleEnabled}
|
||||
handleRunBackupNow={handleRunBackupNow}
|
||||
repository={schedule.repository}
|
||||
setIsEditMode={setIsEditMode}
|
||||
schedule={schedule}
|
||||
volume={schedule.volume}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 sm:p-8 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user