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:
Nico
2025-11-13 20:11:46 +01:00
committed by GitHub
parent 8d7e50508d
commit 95a0d44b45
240 changed files with 5171 additions and 5875 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}