mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: backup schedule frontend
This commit is contained in:
@@ -787,7 +787,9 @@ export type ListSnapshotsData = {
|
|||||||
path: {
|
path: {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
query?: never;
|
query?: {
|
||||||
|
volumeId?: number;
|
||||||
|
};
|
||||||
url: "/api/v1/repositories/{name}/snapshots";
|
url: "/api/v1/repositories/{name}/snapshots";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
|
|||||||
containerClassName,
|
containerClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-card" />
|
<div className={cn("relative container m-auto", className)}>{children}</div>
|
||||||
<div className={cn("relative h-screen", className)}>{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={false}>
|
<SidebarProvider defaultOpen={true}>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<GridBackground>
|
<div className="w-full relative">
|
||||||
<header className="bg-card-header border-b border-border/50">
|
<header className="sticky top-0 z-50 bg-card-header border-b border-border/50">
|
||||||
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto">
|
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<AppBreadcrumb />
|
<AppBreadcrumb />
|
||||||
@@ -69,10 +69,12 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-col p-2 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
<GridBackground>
|
||||||
<Outlet />
|
<main className="flex flex-col p-2 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
||||||
</main>
|
<Outlet />
|
||||||
</GridBackground>
|
</main>
|
||||||
|
</GridBackground>
|
||||||
|
</div>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading, summaryContent }: Props) => {
|
export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading }: Props) => {
|
||||||
console.log("Initial Values:", initialValues);
|
|
||||||
|
|
||||||
const form = useForm<BackupScheduleFormValues>({
|
const form = useForm<BackupScheduleFormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||||
defaultValues: backupScheduleToFormValues(initialValues),
|
defaultValues: backupScheduleToFormValues(initialValues),
|
||||||
@@ -86,6 +84,7 @@ export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading, s
|
|||||||
});
|
});
|
||||||
|
|
||||||
const frequency = form.watch("frequency");
|
const frequency = form.watch("frequency");
|
||||||
|
const formValues = form.watch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -343,8 +342,46 @@ export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading, s
|
|||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
{summaryContent && <div className="h-full">{summaryContent}</div>}
|
<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>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
237
apps/client/app/modules/details/components/schedule-summary.tsx
Normal file
237
apps/client/app/modules/details/components/schedule-summary.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Calendar, Clock, Database, FolderTree, HardDrive, Pencil } 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 { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||||
|
import type { BackupSchedule, Repository, Volume } from "~/lib/types";
|
||||||
|
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
volume: Volume;
|
||||||
|
schedule: BackupSchedule;
|
||||||
|
repository: Repository;
|
||||||
|
handleToggleEnabled: (enabled: boolean) => void;
|
||||||
|
setIsEditMode: (isEdit: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScheduleSummary = (props: Props) => {
|
||||||
|
const { volume, schedule, repository, handleToggleEnabled, setIsEditMode } = props;
|
||||||
|
|
||||||
|
const { data: snapshots, isLoading: loadingSnapshots } = useQuery({
|
||||||
|
...listSnapshotsOptions({
|
||||||
|
path: { name: repository.name },
|
||||||
|
query: { volumeId: volume.id },
|
||||||
|
}),
|
||||||
|
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="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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="border-t">
|
||||||
|
<TableHeader className="bg-card-header">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="uppercase">Snapshot ID</TableHead>
|
||||||
|
<TableHead className="uppercase">Date & Time</TableHead>
|
||||||
|
<TableHead className="uppercase">Size</TableHead>
|
||||||
|
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||||
|
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{snapshots.snapshots.map((s) => (
|
||||||
|
<TableRow key={s.short_id} className="hover:bg-accent/50">
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-strong-accent">{s.short_id}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{new Date(s.time).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">
|
||||||
|
<ByteSize bytes={s.size} base={1024} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatSnapshotDuration(s.duration / 1000)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1"
|
||||||
|
title={s.paths[0]}
|
||||||
|
>
|
||||||
|
{s.paths[0].split("/").filter(Boolean).pop() || "/"}
|
||||||
|
</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className={`text-xs bg-muted text-muted-foreground rounded-md px-2 py-1 cursor-help ${s.paths.length <= 1 ? "hidden" : ""}`}
|
||||||
|
>
|
||||||
|
+{s.paths.length - 1}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-md">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{s.paths.slice(1).map((path) => (
|
||||||
|
<div key={`${s.short_id}-${path}`} className="text-xs">
|
||||||
|
{path}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Database, Plus } from "lucide-react";
|
import { Database, Plus } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { OnOff } from "~/components/onoff";
|
|
||||||
import type { Volume } from "~/lib/types";
|
import type { Volume } from "~/lib/types";
|
||||||
import {
|
import {
|
||||||
listRepositoriesOptions,
|
listRepositoriesOptions,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
} from "~/api-client/@tanstack/react-query.gen";
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/lib/errors";
|
import { parseError } from "~/lib/errors";
|
||||||
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
||||||
|
import { ScheduleSummary } from "../components/schedule-summary";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
@@ -39,6 +39,7 @@ const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: st
|
|||||||
|
|
||||||
export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
|
||||||
const { data: repositoriesData, isLoading: loadingRepositories } = useQuery({
|
const { data: repositoriesData, isLoading: loadingRepositories } = useQuery({
|
||||||
...listRepositoriesOptions(),
|
...listRepositoriesOptions(),
|
||||||
@@ -48,32 +49,7 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
|||||||
...getBackupScheduleForVolumeOptions({ path: { volumeId: volume.id.toString() } }),
|
...getBackupScheduleForVolumeOptions({ path: { volumeId: volume.id.toString() } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isEnabled, setIsEnabled] = useState(existingSchedule?.enabled ?? true);
|
|
||||||
|
|
||||||
const repositories = repositoriesData || [];
|
const repositories = repositoriesData || [];
|
||||||
const selectedRepository = repositories.find((r) => r.id === (existingSchedule?.repositoryId ?? ""));
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
const scheduleLabel = existingSchedule ? existingSchedule.cronExpression : "Every day at 02:00";
|
|
||||||
|
|
||||||
const retentionParts: string[] = [];
|
|
||||||
if (existingSchedule?.retentionPolicy) {
|
|
||||||
const rp = existingSchedule.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: selectedRepository?.name || "No repository selected",
|
|
||||||
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
|
|
||||||
};
|
|
||||||
}, [existingSchedule, selectedRepository, volume.name]);
|
|
||||||
|
|
||||||
const upsertSchedule = useMutation({
|
const upsertSchedule = useMutation({
|
||||||
...upsertBackupScheduleMutation(),
|
...upsertBackupScheduleMutation(),
|
||||||
@@ -104,11 +80,15 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
|||||||
body: {
|
body: {
|
||||||
volumeId: volume.id,
|
volumeId: volume.id,
|
||||||
repositoryId: formValues.repositoryId,
|
repositoryId: formValues.repositoryId,
|
||||||
enabled: existingSchedule ? isEnabled : true,
|
enabled: existingSchedule?.enabled ?? true,
|
||||||
cronExpression,
|
cronExpression,
|
||||||
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (existingSchedule) {
|
||||||
|
setIsEditMode(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadingRepositories || loadingSchedules) {
|
if (loadingRepositories || loadingSchedules) {
|
||||||
@@ -154,7 +134,6 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
|||||||
const handleToggleEnabled = (enabled: boolean) => {
|
const handleToggleEnabled = (enabled: boolean) => {
|
||||||
if (!existingSchedule) return;
|
if (!existingSchedule) return;
|
||||||
|
|
||||||
setIsEnabled(enabled);
|
|
||||||
upsertSchedule.mutate({
|
upsertSchedule.mutate({
|
||||||
body: {
|
body: {
|
||||||
volumeId: existingSchedule.volumeId,
|
volumeId: existingSchedule.volumeId,
|
||||||
@@ -166,87 +145,30 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const repository = repositories.find((repo) => repo.id === existingSchedule?.repositoryId);
|
||||||
|
|
||||||
|
if (existingSchedule && repository && !isEditMode) {
|
||||||
|
return (
|
||||||
|
<ScheduleSummary
|
||||||
|
handleToggleEnabled={handleToggleEnabled}
|
||||||
|
repository={repository}
|
||||||
|
setIsEditMode={setIsEditMode}
|
||||||
|
schedule={existingSchedule}
|
||||||
|
volume={volume}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateScheduleForm
|
<div className="space-y-4">
|
||||||
volume={volume}
|
{existingSchedule && isEditMode && (
|
||||||
initialValues={existingSchedule ?? undefined}
|
<div className="flex justify-end">
|
||||||
onSubmit={handleSubmit}
|
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
||||||
summaryContent={
|
Cancel
|
||||||
existingSchedule ? (
|
</Button>
|
||||||
<Card className="h-full">
|
</div>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
)}
|
||||||
<div>
|
<CreateScheduleForm volume={volume} initialValues={existingSchedule ?? undefined} onSubmit={handleSubmit} />
|
||||||
<CardTitle>Schedule summary</CardTitle>
|
</div>
|
||||||
<CardDescription>Review the backup configuration.</CardDescription>
|
|
||||||
</div>
|
|
||||||
<OnOff isOn={isEnabled} toggle={handleToggleEnabled} enabledLabel="Enabled" disabledLabel="Paused" />
|
|
||||||
</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">{summary.vol}</p>
|
|
||||||
</div>
|
|
||||||
<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">Retention</p>
|
|
||||||
<p className="font-medium">{summary.retentionLabel}</p>
|
|
||||||
</div>
|
|
||||||
{existingSchedule && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{existingSchedule.lastBackupAt
|
|
||||||
? new Date(existingSchedule.lastBackupAt).toLocaleString()
|
|
||||||
: "Never"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-muted-foreground">Status</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{existingSchedule.lastBackupStatus === "success" && "✓ Success"}
|
|
||||||
{existingSchedule.lastBackupStatus === "error" && "✗ Error"}
|
|
||||||
{!existingSchedule.lastBackupStatus && "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Schedule summary</CardTitle>
|
|
||||||
<CardDescription>Review the backup configuration before saving.</CardDescription>
|
|
||||||
</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">{summary.vol}</p>
|
|
||||||
</div>
|
|
||||||
<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">Retention</p>
|
|
||||||
<p className="font-medium">{summary.retentionLabel}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ type Props = {
|
|||||||
|
|
||||||
type Snapshot = ListSnapshotsResponse["snapshots"][0];
|
type Snapshot = ListSnapshotsResponse["snapshots"][0];
|
||||||
|
|
||||||
|
export const formatSnapshotDuration = (seconds: number) => {
|
||||||
|
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (duration.days) parts.push(`${duration.days}d`);
|
||||||
|
if (duration.hours) parts.push(`${duration.hours}h`);
|
||||||
|
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
||||||
|
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
||||||
|
|
||||||
|
return parts.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
@@ -41,18 +53,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||||||
const hasNoSnapshots = snapshots.length === 0;
|
const hasNoSnapshots = snapshots.length === 0;
|
||||||
const hasNoFilteredSnapshots = filteredSnapshots.length === 0 && !hasNoSnapshots;
|
const hasNoFilteredSnapshots = filteredSnapshots.length === 0 && !hasNoSnapshots;
|
||||||
|
|
||||||
const formatSnapshotDuration = (seconds: number) => {
|
|
||||||
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (duration.days) parts.push(`${duration.days}d`);
|
|
||||||
if (duration.hours) parts.push(`${duration.hours}h`);
|
|
||||||
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
|
||||||
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
|
||||||
|
|
||||||
return parts.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (repository.status === "error") {
|
if (repository.status === "error") {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<body className="min-h-dvh">
|
<body>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ const executeBackup = async (scheduleId: number) => {
|
|||||||
throw new NotFoundError("Backup schedule not found");
|
throw new NotFoundError("Backup schedule not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!schedule.enabled) {
|
||||||
|
logger.info(`Backup schedule ${scheduleId} is disabled. Skipping execution.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const volume = await db.query.volumesTable.findFirst({
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
where: eq(volumesTable.id, schedule.volumeId),
|
where: eq(volumesTable.id, schedule.volumeId),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getRepositoryDto,
|
getRepositoryDto,
|
||||||
listRepositoriesDto,
|
listRepositoriesDto,
|
||||||
listSnapshotsDto,
|
listSnapshotsDto,
|
||||||
|
listSnapshotsFilters,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
type ListRepositoriesDto,
|
type ListRepositoriesDto,
|
||||||
@@ -38,9 +39,11 @@ export const repositoriesController = new Hono()
|
|||||||
|
|
||||||
return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
|
return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
|
||||||
})
|
})
|
||||||
.get("/:name/snapshots", listSnapshotsDto, async (c) => {
|
.get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
|
||||||
const { name } = c.req.param();
|
const { name } = c.req.param();
|
||||||
const res = await repositoriesService.listSnapshots(name);
|
const { volumeId } = c.req.valid("query");
|
||||||
|
|
||||||
|
const res = await repositoriesService.listSnapshots(name, Number(volumeId));
|
||||||
|
|
||||||
const snapshots = res.map((snapshot) => {
|
const snapshots = res.map((snapshot) => {
|
||||||
const { summary } = snapshot;
|
const { summary } = snapshot;
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ const listSnapshotsResponse = type({
|
|||||||
|
|
||||||
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
|
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
|
||||||
|
|
||||||
|
export const listSnapshotsFilters = type({
|
||||||
|
volumeId: "string?",
|
||||||
|
});
|
||||||
|
|
||||||
export const listSnapshotsDto = describeRoute({
|
export const listSnapshotsDto = describeRoute({
|
||||||
description: "List all snapshots in a repository",
|
description: "List all snapshots in a repository",
|
||||||
tags: ["Repositories"],
|
tags: ["Repositories"],
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { eq } from "drizzle-orm";
|
|||||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { repositoriesTable } from "../../db/schema";
|
import { repositoriesTable, volumesTable } from "../../db/schema";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
|
|
||||||
const listRepositories = async () => {
|
const listRepositories = async () => {
|
||||||
const repositories = await db.query.repositoriesTable.findMany({});
|
const repositories = await db.query.repositoriesTable.findMany({});
|
||||||
@@ -105,7 +106,7 @@ const deleteRepository = async (name: string) => {
|
|||||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
|
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
|
||||||
};
|
};
|
||||||
|
|
||||||
const listSnapshots = async (name: string) => {
|
const listSnapshots = async (name: string, volumeId?: number) => {
|
||||||
const repository = await db.query.repositoriesTable.findFirst({
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
where: eq(repositoriesTable.name, name),
|
where: eq(repositoriesTable.name, name),
|
||||||
});
|
});
|
||||||
@@ -114,7 +115,22 @@ const listSnapshots = async (name: string) => {
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshots = await restic.snapshots(repository.config);
|
let snapshots = await restic.snapshots(repository.config);
|
||||||
|
|
||||||
|
if (volumeId) {
|
||||||
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
|
where: eq(volumesTable.id, volumeId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!volume) {
|
||||||
|
throw new NotFoundError("Volume not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots = snapshots.filter((snapshot) => {
|
||||||
|
return snapshot.paths.some((path) => path.includes(getVolumePath(volume.name)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return snapshots;
|
return snapshots;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
@@ -31,5 +31,10 @@
|
|||||||
"organizeImports": "off"
|
"organizeImports": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user