mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
refactor: unify backend and frontend servers (#3)
* refactor: unify backend and frontend servers * refactor: correct paths for openapi & drizzle * refactor: move api-client to client * fix: drizzle paths * chore: fix linting issues * fix: form reset issue
This commit is contained in:
191
app/client/modules/backups/routes/backup-details.tsx
Normal file
191
app/client/modules/backups/routes/backup-details.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { redirect, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
getBackupScheduleOptions,
|
||||
runBackupNowMutation,
|
||||
deleteBackupScheduleMutation,
|
||||
listSnapshotsOptions,
|
||||
updateBackupScheduleMutation,
|
||||
stopBackupMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { getCronExpression } from "~/utils/utils";
|
||||
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
||||
import { ScheduleSummary } from "../components/schedule-summary";
|
||||
import type { Route } from "./+types/backup-details";
|
||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||
import { getBackupSchedule } from "~/client/api-client";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Backup Job Details" },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||
|
||||
if (!data) return redirect("/backups");
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||
const navigate = useNavigate();
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const formId = useId();
|
||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||
|
||||
const { data: schedule } = useQuery({
|
||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const {
|
||||
data: snapshots,
|
||||
isLoading,
|
||||
failureReason,
|
||||
} = useQuery({
|
||||
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
|
||||
});
|
||||
|
||||
const updateSchedule = useMutation({
|
||||
...updateBackupScheduleMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup schedule saved successfully");
|
||||
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");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to start backup", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
const stopBackup = useMutation({
|
||||
...stopBackupMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup stopped successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to stop backup", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteSchedule = useMutation({
|
||||
...deleteBackupScheduleMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup schedule deleted successfully");
|
||||
navigate("/backups");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete backup schedule", { 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;
|
||||
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
repositoryId: formValues.repositoryId,
|
||||
enabled: schedule.enabled,
|
||||
cronExpression,
|
||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (enabled: boolean) => {
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
repositoryId: schedule.repositoryId,
|
||||
enabled,
|
||||
cronExpression: schedule.cronExpression,
|
||||
retentionPolicy: schedule.retentionPolicy || undefined,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatterns: schedule.excludePatterns || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div>
|
||||
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
|
||||
<div className="flex justify-end mt-4 gap-2">
|
||||
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}>
|
||||
Update schedule
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSnapshot = snapshots?.find((s) => s.short_id === selectedSnapshotId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ScheduleSummary
|
||||
handleToggleEnabled={handleToggleEnabled}
|
||||
handleRunBackupNow={() => runBackupNow.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
handleStopBackup={() => stopBackup.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
handleDeleteSchedule={() => deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
setIsEditMode={setIsEditMode}
|
||||
schedule={schedule}
|
||||
/>
|
||||
<SnapshotTimeline
|
||||
loading={isLoading}
|
||||
snapshots={snapshots ?? []}
|
||||
snapshotId={selectedSnapshot?.short_id}
|
||||
error={failureReason?.message}
|
||||
onSnapshotSelect={setSelectedSnapshotId}
|
||||
/>
|
||||
{selectedSnapshot && (
|
||||
<SnapshotFileBrowser
|
||||
key={selectedSnapshot?.short_id}
|
||||
snapshot={selectedSnapshot}
|
||||
repositoryName={schedule.repository.name}
|
||||
volume={schedule.volume}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
app/client/modules/backups/routes/backups.tsx
Normal file
121
app/client/modules/backups/routes/backups.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { BackupStatusDot } from "../components/backup-status-dot";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { Route } from "./+types/backups";
|
||||
import { listBackupSchedules } from "~/client/api-client";
|
||||
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Backup Jobs" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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 auto-rows-fr">
|
||||
{schedules.map((schedule) => (
|
||||
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
|
||||
<Card key={schedule.id} className="flex flex-col h-full">
|
||||
<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 shrink-0" />
|
||||
<CardTitle className="text-lg truncate">
|
||||
Volume <span className="text-strong-accent">{schedule.volume.name}</span>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<BackupStatusDot
|
||||
enabled={schedule.enabled}
|
||||
hasError={!!schedule.lastBackupError}
|
||||
isInProgress={schedule.lastBackupStatus === "in_progress"}
|
||||
/>
|
||||
</div>
|
||||
<CardDescription className="flex items-center gap-2 mt-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<span className="truncate">{schedule.repository.name}</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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
<Link to="/backups/create">
|
||||
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2">
|
||||
<Plus className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">Create a backup job</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
app/client/modules/backups/routes/create-backup.tsx
Normal file
185
app/client/modules/backups/routes/create-backup.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useMutation, useQuery } 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 "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { EmptyState } from "~/client/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 "~/client/api-client";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Create Backup Job" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new automated backup job for your volumes.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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 formId = useId();
|
||||
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");
|
||||
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,
|
||||
includePatterns: formValues.includePatterns,
|
||||
excludePatterns: formValues.excludePatterns,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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} formId={formId} />
|
||||
<div className="flex justify-end mt-4 gap-2">
|
||||
<Button type="submit" variant="primary" form={formId} loading={createSchedule.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<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-linear-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