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:
@@ -0,0 +1,388 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { RepositoryIcon } from "~/components/repository-icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import type { BackupSchedule, Volume } from "~/lib/types";
|
||||
import { deepClean } from "~/utils/object";
|
||||
|
||||
const formSchema = type({
|
||||
repositoryId: "string",
|
||||
excludePatterns: "string[]?",
|
||||
includePatterns: "string[]?",
|
||||
frequency: "string",
|
||||
dailyTime: "string?",
|
||||
weeklyDay: "string?",
|
||||
keepLast: "number?",
|
||||
keepHourly: "number?",
|
||||
keepDaily: "number?",
|
||||
keepWeekly: "number?",
|
||||
keepMonthly: "number?",
|
||||
keepYearly: "number?",
|
||||
});
|
||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||
|
||||
export const weeklyDays = [
|
||||
{ label: "Monday", value: "1" },
|
||||
{ label: "Tuesday", value: "2" },
|
||||
{ label: "Wednesday", value: "3" },
|
||||
{ label: "Thursday", value: "4" },
|
||||
{ label: "Friday", value: "5" },
|
||||
{ label: "Saturday", value: "6" },
|
||||
{ label: "Sunday", value: "0" },
|
||||
];
|
||||
|
||||
export type BackupScheduleFormValues = typeof formSchema.infer;
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
initialValues?: BackupSchedule;
|
||||
onSubmit: (data: BackupScheduleFormValues) => void;
|
||||
loading?: boolean;
|
||||
summaryContent?: React.ReactNode;
|
||||
};
|
||||
|
||||
const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => {
|
||||
if (!schedule) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = schedule.cronExpression.split(" ");
|
||||
const [minutePart, hourPart, , , dayOfWeekPart] = parts;
|
||||
|
||||
const isHourly = hourPart === "*";
|
||||
const isDaily = !isHourly && dayOfWeekPart === "*";
|
||||
const frequency = isHourly ? "hourly" : isDaily ? "daily" : "weekly";
|
||||
|
||||
const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`;
|
||||
|
||||
const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined;
|
||||
|
||||
return {
|
||||
repositoryId: schedule.repositoryId,
|
||||
frequency,
|
||||
dailyTime,
|
||||
weeklyDay,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading }: Props) => {
|
||||
const form = useForm<BackupScheduleFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
defaultValues: backupScheduleToFormValues(initialValues),
|
||||
});
|
||||
|
||||
const { data: repositoriesData } = useQuery({
|
||||
...listRepositoriesOptions(),
|
||||
});
|
||||
|
||||
const frequency = form.watch("frequency");
|
||||
const formValues = form.watch();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Backup automation</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Schedule automated backups of <strong>{volume.name}</strong> to a secure repository.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Backup repository</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a repository" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repositoriesData?.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<RepositoryIcon backend={repo.type} />
|
||||
{repo.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Choose where encrypted backups for <strong>{volume.name}</strong> will be stored.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="frequency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backup frequency</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">Hourly</SelectItem>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Define how often snapshots should be taken.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{frequency !== "hourly" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dailyTime"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Execution time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="time" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Time of day when the backup will run.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency === "weekly" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="weeklyDay"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Execution day</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a day" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{weeklyDays.map((day) => (
|
||||
<SelectItem key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Choose which day of the week to run the backup.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Retention policy</CardTitle>
|
||||
<CardDescription>Define how many snapshots to keep. Leave empty to keep all.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLast"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep last N snapshots</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Optional"
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the N most recent snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepHourly"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep hourly</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Optional"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepDaily"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep daily</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="e.g., 7"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N daily snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepWeekly"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep weekly</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="e.g., 4"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepMonthly"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep monthly</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="e.g., 6"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepYearly"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep yearly</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Optional"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button type="submit" className="ml-auto" variant="default" loading={loading}>
|
||||
{initialValues ? "Update schedule" : "Create schedule"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Schedule summary</CardTitle>
|
||||
<CardDescription>Review the backup configuration.</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Volume</p>
|
||||
<p className="font-medium">{volume.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
|
||||
<p className="font-medium">
|
||||
{frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Repository</p>
|
||||
<p className="font-medium">
|
||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Retention</p>
|
||||
<p className="font-medium">
|
||||
{Object.entries(formValues)
|
||||
.filter(([key, value]) => key.startsWith("keep") && Boolean(value))
|
||||
.map(([key, value]) => {
|
||||
const label = key.replace("keep", "").toLowerCase();
|
||||
return `${value} ${label}`;
|
||||
})
|
||||
.join(", ") || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
162
apps/client/app/modules/backups/components/schedule-summary.tsx
Normal file
162
apps/client/app/modules/backups/components/schedule-summary.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database, Pencil, Play } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { ByteSize } from "~/components/bytes-size";
|
||||
import { OnOff } from "~/components/onoff";
|
||||
import { SnapshotsTable } from "~/components/snapshots-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import type { BackupSchedule, Repository, Volume } from "~/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
schedule: BackupSchedule;
|
||||
repository: Repository;
|
||||
handleToggleEnabled: (enabled: boolean) => void;
|
||||
handleRunBackupNow: () => void;
|
||||
setIsEditMode: (isEdit: boolean) => void;
|
||||
};
|
||||
|
||||
export const ScheduleSummary = (props: Props) => {
|
||||
const { volume, schedule, repository, handleToggleEnabled, handleRunBackupNow, setIsEditMode } = props;
|
||||
|
||||
const { data: snapshots, isLoading: loadingSnapshots } = useQuery({
|
||||
...listSnapshotsOptions({
|
||||
path: { name: repository.name },
|
||||
query: { volumeId: volume.id.toString() },
|
||||
}),
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const scheduleLabel = schedule ? schedule.cronExpression : "-";
|
||||
|
||||
const retentionParts: string[] = [];
|
||||
if (schedule?.retentionPolicy) {
|
||||
const rp = schedule.retentionPolicy;
|
||||
if (rp.keepLast) retentionParts.push(`${rp.keepLast} last`);
|
||||
if (rp.keepHourly) retentionParts.push(`${rp.keepHourly} hourly`);
|
||||
if (rp.keepDaily) retentionParts.push(`${rp.keepDaily} daily`);
|
||||
if (rp.keepWeekly) retentionParts.push(`${rp.keepWeekly} weekly`);
|
||||
if (rp.keepMonthly) retentionParts.push(`${rp.keepMonthly} monthly`);
|
||||
if (rp.keepYearly) retentionParts.push(`${rp.keepYearly} yearly`);
|
||||
}
|
||||
|
||||
return {
|
||||
vol: volume.name,
|
||||
scheduleLabel,
|
||||
repositoryLabel: schedule.repositoryId || "No repository selected",
|
||||
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
|
||||
};
|
||||
}, [schedule, volume.name]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Backup schedule</CardTitle>
|
||||
<CardDescription>Automated backup configuration for {volume.name}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<OnOff isOn={schedule.enabled} toggle={handleToggleEnabled} enabledLabel="Enabled" disabledLabel="Paused" />
|
||||
<Button variant="default" size="sm" onClick={handleRunBackupNow}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Backup Now
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit schedule
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
|
||||
<p className="font-medium">{summary.scheduleLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Repository</p>
|
||||
<p className="font-medium">{summary.repositoryLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
|
||||
<p className="font-medium">
|
||||
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Next backup</p>
|
||||
<p className="font-medium">
|
||||
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Status</p>
|
||||
<p className="font-medium">
|
||||
{schedule.lastBackupStatus === "success" && "✓ Success"}
|
||||
{schedule.lastBackupStatus === "error" && "✗ Error"}
|
||||
{!schedule.lastBackupStatus && "—"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="p-0 gap-0">
|
||||
<CardHeader className="p-4 bg-card-header">
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-4 justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle>Snapshots</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Backup snapshots for this volume. Total: {snapshots?.snapshots.length}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{loadingSnapshots && !snapshots ? (
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Loading snapshots...</p>
|
||||
</CardContent>
|
||||
) : !snapshots ? (
|
||||
<CardContent className="flex flex-col items-center justify-center text-center py-16 px-4">
|
||||
<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">
|
||||
<h3 className="text-2xl font-semibold text-foreground">No snapshots yet</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Snapshots are point-in-time backups of your data. The next scheduled backup will create the first
|
||||
snapshot.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
) : (
|
||||
<>
|
||||
<SnapshotsTable snapshots={snapshots.snapshots} repositoryName={repository.name} />
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
|
||||
<span>{`Showing ${snapshots.snapshots.length} of ${snapshots.snapshots.length}`}</span>
|
||||
<span>
|
||||
Total size:
|
||||
<span className="text-strong-accent font-medium">
|
||||
<ByteSize
|
||||
bytes={snapshots.snapshots.reduce((sum, s) => sum + s.size, 0)}
|
||||
base={1024}
|
||||
maximumFractionDigits={1}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
158
apps/client/app/modules/backups/routes/backup-details.tsx
Normal file
158
apps/client/app/modules/backups/routes/backup-details.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
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 { 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 }>();
|
||||
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>
|
||||
);
|
||||
}
|
||||
120
apps/client/app/modules/backups/routes/backups.tsx
Normal file
120
apps/client/app/modules/backups/routes/backups.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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 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,
|
||||
});
|
||||
|
||||
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 (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user