mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(frontend): backup jobs page
This commit is contained in:
@@ -670,7 +670,7 @@ export type ListRepositoriesResponses = {
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "local";
|
backend: "local";
|
||||||
path: string;
|
name: string;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -697,7 +697,7 @@ export type CreateRepositoryData = {
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "local";
|
backend: "local";
|
||||||
path: string;
|
name: string;
|
||||||
};
|
};
|
||||||
name: string;
|
name: string;
|
||||||
compressionMode?: "auto" | "better" | "fastest" | "max" | "off";
|
compressionMode?: "auto" | "better" | "fastest" | "max" | "off";
|
||||||
@@ -767,7 +767,7 @@ export type GetRepositoryResponses = {
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "local";
|
backend: "local";
|
||||||
path: string;
|
name: string;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -856,7 +856,6 @@ export type RestoreSnapshotData = {
|
|||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
exclude?: Array<string>;
|
exclude?: Array<string>;
|
||||||
include?: Array<string>;
|
include?: Array<string>;
|
||||||
path?: string;
|
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1018,6 +1017,29 @@ export type GetBackupScheduleResponses = {
|
|||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: "error" | "success" | null;
|
lastBackupStatus: "error" | "success" | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
|
repository: {
|
||||||
|
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
|
||||||
|
config:
|
||||||
|
| {
|
||||||
|
accessKeyId: string;
|
||||||
|
backend: "s3";
|
||||||
|
bucket: string;
|
||||||
|
endpoint: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
backend: "local";
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
id: string;
|
||||||
|
lastChecked: number | null;
|
||||||
|
lastError: string | null;
|
||||||
|
name: string;
|
||||||
|
status: "error" | "healthy" | "unknown" | null;
|
||||||
|
type: "local" | "s3";
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
keepDaily?: number;
|
keepDaily?: number;
|
||||||
@@ -1029,6 +1051,48 @@ export type GetBackupScheduleResponses = {
|
|||||||
keepYearly?: number;
|
keepYearly?: number;
|
||||||
} | null;
|
} | null;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
volume: {
|
||||||
|
autoRemount: boolean;
|
||||||
|
config:
|
||||||
|
| {
|
||||||
|
backend: "directory";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
backend: "nfs";
|
||||||
|
exportPath: string;
|
||||||
|
server: string;
|
||||||
|
version: "3" | "4" | "4.1";
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
backend: "smb";
|
||||||
|
password: string;
|
||||||
|
server: string;
|
||||||
|
share: string;
|
||||||
|
username: string;
|
||||||
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
|
port?: number;
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
backend: "webdav";
|
||||||
|
path: string;
|
||||||
|
server: string;
|
||||||
|
port?: number;
|
||||||
|
password?: string;
|
||||||
|
ssl?: boolean;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
id: number;
|
||||||
|
lastError: string | null;
|
||||||
|
lastHealthCheck: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
status: "error" | "mounted" | "unmounted";
|
||||||
|
type: "directory" | "nfs" | "smb" | "webdav";
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
volumeId: number;
|
volumeId: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Database, HardDrive, Mountain } from "lucide-react";
|
import { CalendarClock, Database, HardDrive, Mountain } from "lucide-react";
|
||||||
import { Link, NavLink } from "react-router";
|
import { Link, NavLink } from "react-router";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -25,6 +25,11 @@ const items = [
|
|||||||
url: "/repositories",
|
url: "/repositories",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Backup jobs",
|
||||||
|
url: "/backup-jobs",
|
||||||
|
icon: CalendarClock,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
@@ -134,23 +134,6 @@ export const CreateRepositoryForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{watchedBackend === "local" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="/path/to/repository" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Local filesystem path where the repository will be stored.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{watchedBackend === "s3" && (
|
{watchedBackend === "s3" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
36
apps/client/app/components/ui/badge.tsx
Normal file
36
apps/client/app/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
|
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -42,6 +42,23 @@ export function generateBreadcrumbs(pathname: string, params: Record<string, str
|
|||||||
return breadcrumbs;
|
return breadcrumbs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/backup-jobs")) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
label: "Backup jobs",
|
||||||
|
href: "/backup-jobs",
|
||||||
|
isCurrentPage: pathname === "/backup-jobs",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pathname.startsWith("/backup-jobs/") && params.scheduleId) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
label: `Schedule #${params.scheduleId}`,
|
||||||
|
isCurrentPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return breadcrumbs;
|
||||||
|
}
|
||||||
|
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: "Volumes",
|
label: "Volumes",
|
||||||
href: "/volumes",
|
href: "/volumes",
|
||||||
|
|||||||
@@ -133,8 +133,8 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
|||||||
To schedule automated backups, you need to create a repository first. Repositories are secure storage
|
To schedule automated backups, you need to create a repository first. Repositories are secure storage
|
||||||
locations where your backups will be stored.
|
locations where your backups will be stored.
|
||||||
</p>
|
</p>
|
||||||
<Button asChild>
|
<Button>
|
||||||
<Link to="/repositories">
|
<Link to="/repositories" className="flex items-center">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create a repository
|
Create a repository
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
|
|||||||
path: { name },
|
path: { name },
|
||||||
body: {
|
body: {
|
||||||
snapshotId,
|
snapshotId,
|
||||||
path: values.path || undefined,
|
|
||||||
include: include && include.length > 0 ? include : undefined,
|
include: include && include.length > 0 ? include : undefined,
|
||||||
exclude: exclude && exclude.length > 0 ? exclude : undefined,
|
exclude: exclude && exclude.length > 0 ? exclude : undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export default [
|
|||||||
route("/", "./routes/root.tsx"),
|
route("/", "./routes/root.tsx"),
|
||||||
route("volumes", "./routes/home.tsx"),
|
route("volumes", "./routes/home.tsx"),
|
||||||
route("volumes/:name", "./routes/details.tsx"),
|
route("volumes/:name", "./routes/details.tsx"),
|
||||||
|
route("backup-jobs", "./routes/backup-jobs.tsx"),
|
||||||
|
route("backup-jobs/:scheduleId", "./routes/schedule-details.tsx"),
|
||||||
route("repositories", "./modules/repositories/routes/repositories.tsx"),
|
route("repositories", "./modules/repositories/routes/repositories.tsx"),
|
||||||
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
|
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
|
||||||
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
|
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
|
||||||
|
|||||||
112
apps/client/app/routes/backup-jobs.tsx
Normal file
112
apps/client/app/routes/backup-jobs.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/client/app/routes/schedule-details.tsx
Normal file
175
apps/client/app/routes/schedule-details.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ import type {
|
|||||||
repositoryConfigSchema,
|
repositoryConfigSchema,
|
||||||
RepositoryStatus,
|
RepositoryStatus,
|
||||||
} from "@ironmount/schemas/restic";
|
} from "@ironmount/schemas/restic";
|
||||||
import { sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volumes Table
|
||||||
|
*/
|
||||||
export const volumesTable = sqliteTable("volumes_table", {
|
export const volumesTable = sqliteTable("volumes_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull().unique(),
|
||||||
@@ -20,9 +23,11 @@ export const volumesTable = sqliteTable("volumes_table", {
|
|||||||
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
||||||
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Volume = typeof volumesTable.$inferSelect;
|
export type Volume = typeof volumesTable.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Users Table
|
||||||
|
*/
|
||||||
export const usersTable = sqliteTable("users_table", {
|
export const usersTable = sqliteTable("users_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
username: text().notNull().unique(),
|
username: text().notNull().unique(),
|
||||||
@@ -30,9 +35,7 @@ export const usersTable = sqliteTable("users_table", {
|
|||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type User = typeof usersTable.$inferSelect;
|
export type User = typeof usersTable.$inferSelect;
|
||||||
|
|
||||||
export const sessionsTable = sqliteTable("sessions_table", {
|
export const sessionsTable = sqliteTable("sessions_table", {
|
||||||
id: text().primaryKey(),
|
id: text().primaryKey(),
|
||||||
userId: int("user_id")
|
userId: int("user_id")
|
||||||
@@ -41,9 +44,11 @@ export const sessionsTable = sqliteTable("sessions_table", {
|
|||||||
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Session = typeof sessionsTable.$inferSelect;
|
export type Session = typeof sessionsTable.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repositories Table
|
||||||
|
*/
|
||||||
export const repositoriesTable = sqliteTable("repositories_table", {
|
export const repositoriesTable = sqliteTable("repositories_table", {
|
||||||
id: text().primaryKey(),
|
id: text().primaryKey(),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull().unique(),
|
||||||
@@ -56,9 +61,11 @@ export const repositoriesTable = sqliteTable("repositories_table", {
|
|||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Repository = typeof repositoriesTable.$inferSelect;
|
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup Schedules Table
|
||||||
|
*/
|
||||||
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
volumeId: int("volume_id")
|
volumeId: int("volume_id")
|
||||||
@@ -88,5 +95,14 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one }) => ({
|
||||||
|
volume: one(volumesTable, {
|
||||||
|
fields: [backupSchedulesTable.volumeId],
|
||||||
|
references: [volumesTable.id],
|
||||||
|
}),
|
||||||
|
repository: one(repositoriesTable, {
|
||||||
|
fields: [backupSchedulesTable.repositoryId],
|
||||||
|
references: [repositoriesTable.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
import { volumeSchema } from "../volumes/volume.dto";
|
||||||
|
import { repositorySchema } from "../repositories/repositories.dto";
|
||||||
|
|
||||||
const retentionPolicySchema = type({
|
const retentionPolicySchema = type({
|
||||||
keepLast: "number?",
|
keepLast: "number?",
|
||||||
@@ -58,7 +60,12 @@ export const listBackupSchedulesDto = describeRoute({
|
|||||||
/**
|
/**
|
||||||
* Get a single backup schedule
|
* Get a single backup schedule
|
||||||
*/
|
*/
|
||||||
export const getBackupScheduleResponse = backupScheduleSchema;
|
export const getBackupScheduleResponse = backupScheduleSchema.and(
|
||||||
|
type({
|
||||||
|
volume: volumeSchema,
|
||||||
|
repository: repositorySchema,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
|
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ const listSchedules = async () => {
|
|||||||
const getSchedule = async (scheduleId: number) => {
|
const getSchedule = async (scheduleId: number) => {
|
||||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
where: eq(volumesTable.id, scheduleId),
|
where: eq(volumesTable.id, scheduleId),
|
||||||
|
with: {
|
||||||
|
volume: true,
|
||||||
|
repository: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
const repositorySchema = type({
|
export const repositorySchema = type({
|
||||||
id: "string",
|
id: "string",
|
||||||
name: "string",
|
name: "string",
|
||||||
type: type.valueOf(REPOSITORY_BACKENDS),
|
type: type.valueOf(REPOSITORY_BACKENDS),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "@ironmount/sc
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
const volumeSchema = type({
|
export const volumeSchema = type({
|
||||||
id: "number",
|
id: "number",
|
||||||
name: "string",
|
name: "string",
|
||||||
path: "string",
|
path: "string",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const ensurePassfile = async () => {
|
|||||||
const buildRepoUrl = (config: RepositoryConfig): string => {
|
const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "local":
|
case "local":
|
||||||
return config.path;
|
return `/repositories/${config.name}`;
|
||||||
case "s3":
|
case "s3":
|
||||||
return `s3:${config.endpoint}/${config.bucket}`;
|
return `s3:${config.endpoint}/${config.bucket}`;
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- ./data/volumes/:/volumes
|
- ./data/volumes/:/volumes
|
||||||
|
- ./data/repositories/:/repositories
|
||||||
# - /proc:/host/proc:ro
|
# - /proc:/host/proc:ro
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts";
|
import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: "http://192.168.2.42:4096/api/v1/openapi.json",
|
input: "http://localhost:4096/api/v1/openapi.json",
|
||||||
output: {
|
output: {
|
||||||
path: "./apps/client/app/api-client",
|
path: "./apps/client/app/api-client",
|
||||||
format: "biome",
|
format: "biome",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const s3RepositoryConfigSchema = type({
|
|||||||
|
|
||||||
export const localRepositoryConfigSchema = type({
|
export const localRepositoryConfigSchema = type({
|
||||||
backend: "'local'",
|
backend: "'local'",
|
||||||
path: "string",
|
name: "string",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const repositoryConfigSchema = s3RepositoryConfigSchema.or(localRepositoryConfigSchema);
|
export const repositoryConfigSchema = s3RepositoryConfigSchema.or(localRepositoryConfigSchema);
|
||||||
|
|||||||
Reference in New Issue
Block a user