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:
106
app/client/modules/auth/routes/download-recovery-key.tsx
Normal file
106
app/client/modules/auth/routes/download-recovery-key.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/download-recovery-key";
|
||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Download Recovery Key" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Download your backup recovery key to ensure you can restore your data.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function DownloadRecoveryKeyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const downloadResticPassword = useMutation({
|
||||
...downloadResticPasswordMutation(),
|
||||
onSuccess: (data) => {
|
||||
const blob = new Blob([data], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "restic.pass";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Recovery key downloaded successfully!");
|
||||
navigate("/volumes", { replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to download recovery key", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
downloadResticPassword.mutate({
|
||||
body: {
|
||||
password,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Download Your Recovery Key"
|
||||
description="This is a critical step to ensure you can recover your backups"
|
||||
>
|
||||
<Alert variant="warning" className="mb-6">
|
||||
<AlertTriangle className="size-5" />
|
||||
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your Restic password is essential for recovering your backup data. If you lose access to this server without
|
||||
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Confirm Your Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
disabled={downloadResticPassword.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||
<Download size={16} className="mr-2" />
|
||||
Download Recovery Key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
115
app/client/modules/auth/routes/login.tsx
Normal file
115
app/client/modules/auth/routes/login.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/login";
|
||||
import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Login" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Sign in to your Ironmount account.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const loginSchema = type({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=1",
|
||||
});
|
||||
|
||||
type LoginFormValues = typeof loginSchema.inferIn;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: arktypeResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const login = useMutation({
|
||||
...loginMutation(),
|
||||
onSuccess: async (data) => {
|
||||
if (data.user && !data.user.hasDownloadedResticPassword) {
|
||||
navigate("/download-recovery-key");
|
||||
} else {
|
||||
navigate("/volumes");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Login failed", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: LoginFormValues) => {
|
||||
login.mutate({
|
||||
body: {
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" placeholder="admin" disabled={login.isPending} autoFocus />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
onClick={() => toast.info("Password reset not implemented")}
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" disabled={login.isPending} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={login.isPending}>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
146
app/client/modules/auth/routes/onboarding.tsx
Normal file
146
app/client/modules/auth/routes/onboarding.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/onboarding";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { registerMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Onboarding" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Welcome to Ironmount. Create your admin account to get started.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const onboardingSchema = type({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=8",
|
||||
confirmPassword: "string>=1",
|
||||
});
|
||||
|
||||
type OnboardingFormValues = typeof onboardingSchema.inferIn;
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<OnboardingFormValues>({
|
||||
resolver: arktypeResolver(onboardingSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const registerUser = useMutation({
|
||||
...registerMutation(),
|
||||
onSuccess: async () => {
|
||||
toast.success("Admin user created successfully!");
|
||||
navigate("/download-recovery-key");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error("Failed to create admin user", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: OnboardingFormValues) => {
|
||||
if (values.password !== values.confirmPassword) {
|
||||
form.setError("confirmPassword", {
|
||||
type: "manual",
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
registerUser.mutate({
|
||||
body: {
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Welcome to Ironmount" description="Create the admin user to get started">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
|
||||
</FormControl>
|
||||
<FormDescription>Choose a username for the admin account</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Enter a secure password"
|
||||
disabled={registerUser.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Password must be at least 8 characters long.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Re-enter your password"
|
||||
disabled={registerUser.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={registerUser.isPending}>
|
||||
Create Admin User
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
100
app/client/modules/backups/components/backup-progress-card.tsx
Normal file
100
app/client/modules/backups/components/backup-progress-card.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ByteSize, formatBytes } from "~/client/components/bytes-size";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Progress } from "~/client/components/ui/progress";
|
||||
import { type BackupProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
|
||||
type Props = {
|
||||
scheduleId: number;
|
||||
};
|
||||
|
||||
export const BackupProgressCard = ({ scheduleId }: Props) => {
|
||||
const { addEventListener } = useServerEvents();
|
||||
const [progress, setProgress] = useState<BackupProgressEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = addEventListener("backup:progress", (data) => {
|
||||
const progressData = data as BackupProgressEvent;
|
||||
if (progressData.scheduleId === scheduleId) {
|
||||
setProgress(progressData);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeComplete = addEventListener("backup:completed", (data) => {
|
||||
const completedData = data as { scheduleId: number };
|
||||
if (completedData.scheduleId === scheduleId) {
|
||||
setProgress(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeComplete();
|
||||
};
|
||||
}, [addEventListener, scheduleId]);
|
||||
|
||||
if (!progress) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="font-medium">Backup in progress</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const percentDone = Math.round(progress.percent_done * 100);
|
||||
const currentFile = progress.current_files[0] || "";
|
||||
const fileName = currentFile.split("/").pop() || currentFile;
|
||||
const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="font-medium">Backup in progress</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary">{percentDone}%</span>
|
||||
</div>
|
||||
|
||||
<Progress value={percentDone} className="h-2" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
||||
<p className="font-medium">
|
||||
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Data</p>
|
||||
<p className="font-medium">
|
||||
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
||||
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
||||
<p className="font-medium">
|
||||
{progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fileName && (
|
||||
<div className="pt-2 border-t border-border">
|
||||
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
|
||||
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile}>
|
||||
{fileName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
71
app/client/modules/backups/components/backup-status-dot.tsx
Normal file
71
app/client/modules/backups/components/backup-status-dot.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
|
||||
type BackupStatus = "active" | "paused" | "error" | "in_progress";
|
||||
|
||||
export const BackupStatusDot = ({
|
||||
enabled,
|
||||
hasError,
|
||||
isInProgress,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
hasError?: boolean;
|
||||
isInProgress?: boolean;
|
||||
}) => {
|
||||
let status: BackupStatus = "paused";
|
||||
if (isInProgress) {
|
||||
status = "in_progress";
|
||||
} else if (hasError) {
|
||||
status = "error";
|
||||
} else if (enabled) {
|
||||
status = "active";
|
||||
}
|
||||
|
||||
const statusMapping = {
|
||||
active: {
|
||||
color: "bg-green-500",
|
||||
colorLight: "bg-emerald-400",
|
||||
animated: true,
|
||||
label: "Active",
|
||||
},
|
||||
paused: {
|
||||
color: "bg-gray-500",
|
||||
colorLight: "bg-gray-400",
|
||||
animated: false,
|
||||
label: "Paused",
|
||||
},
|
||||
error: {
|
||||
color: "bg-red-500",
|
||||
colorLight: "bg-red-400",
|
||||
animated: true,
|
||||
label: "Error",
|
||||
},
|
||||
in_progress: {
|
||||
color: "bg-blue-500",
|
||||
colorLight: "bg-blue-400",
|
||||
animated: true,
|
||||
label: "Backup in progress",
|
||||
},
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="relative flex size-3 mx-auto">
|
||||
{statusMapping.animated && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||
`${statusMapping.colorLight}`,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{statusMapping.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
530
app/client/modules/backups/components/create-schedule-form.tsx
Normal file
530
app/client/modules/backups/components/create-schedule-form.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Textarea } from "~/client/components/ui/textarea";
|
||||
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
|
||||
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||
import { deepClean } from "~/utils/object";
|
||||
|
||||
const internalFormSchema = type({
|
||||
repositoryId: "string",
|
||||
excludePatternsText: "string?",
|
||||
includePatterns: "string[]?",
|
||||
frequency: "string",
|
||||
dailyTime: "string?",
|
||||
weeklyDay: "string?",
|
||||
keepLast: "number?",
|
||||
keepHourly: "number?",
|
||||
keepDaily: "number?",
|
||||
keepWeekly: "number?",
|
||||
keepMonthly: "number?",
|
||||
keepYearly: "number?",
|
||||
});
|
||||
const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
||||
|
||||
export const weeklyDays = [
|
||||
{ label: "Monday", value: "1" },
|
||||
{ label: "Tuesday", value: "2" },
|
||||
{ label: "Wednesday", value: "3" },
|
||||
{ label: "Thursday", value: "4" },
|
||||
{ label: "Friday", value: "5" },
|
||||
{ label: "Saturday", value: "6" },
|
||||
{ label: "Sunday", value: "0" },
|
||||
];
|
||||
|
||||
type InternalFormValues = typeof internalFormSchema.infer;
|
||||
|
||||
export type BackupScheduleFormValues = Omit<InternalFormValues, "excludePatternsText"> & {
|
||||
excludePatterns?: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
initialValues?: BackupSchedule;
|
||||
onSubmit: (data: BackupScheduleFormValues) => void;
|
||||
loading?: boolean;
|
||||
summaryContent?: React.ReactNode;
|
||||
formId: string;
|
||||
};
|
||||
|
||||
const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
|
||||
if (!schedule) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = schedule.cronExpression.split(" ");
|
||||
const [minutePart, hourPart, , , dayOfWeekPart] = parts;
|
||||
|
||||
const isHourly = hourPart === "*";
|
||||
const isDaily = !isHourly && dayOfWeekPart === "*";
|
||||
const frequency = isHourly ? "hourly" : isDaily ? "daily" : "weekly";
|
||||
|
||||
const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`;
|
||||
|
||||
const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined;
|
||||
|
||||
return {
|
||||
repositoryId: schedule.repositoryId,
|
||||
frequency,
|
||||
dailyTime,
|
||||
weeklyDay,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
||||
const form = useForm<InternalFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof internalFormSchema),
|
||||
defaultValues: backupScheduleToFormValues(initialValues),
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: InternalFormValues) => {
|
||||
// Convert excludePatternsText string to excludePatterns array
|
||||
const { excludePatternsText, ...rest } = data;
|
||||
const excludePatterns = excludePatternsText
|
||||
? excludePatternsText
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
onSubmit({
|
||||
...rest,
|
||||
excludePatterns,
|
||||
});
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
const { data: repositoriesData } = useQuery({
|
||||
...listRepositoriesOptions(),
|
||||
});
|
||||
|
||||
const frequency = form.watch("frequency");
|
||||
const formValues = form.watch();
|
||||
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set(initialValues?.includePatterns || []));
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(paths: Set<string>) => {
|
||||
setSelectedPaths(paths);
|
||||
form.setValue("includePatterns", Array.from(paths));
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"
|
||||
id={formId}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Backup automation</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
Schedule automated backups of <strong>{volume.name}</strong> to a secure repository.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Backup repository</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a repository" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repositoriesData?.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<RepositoryIcon backend={repo.type} />
|
||||
{repo.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Choose where encrypted backups for <strong>{volume.name}</strong> will be stored.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="frequency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Backup frequency</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">Hourly</SelectItem>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Define how often snapshots should be taken.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{frequency !== "hourly" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dailyTime"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Execution time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="time" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Time of day when the backup will run.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency === "weekly" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="weeklyDay"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Execution day</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a day" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{weeklyDays.map((day) => (
|
||||
<SelectItem key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>Choose which day of the week to run the backup.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Backup paths</CardTitle>
|
||||
<CardDescription>
|
||||
Select which folders to include in the backup. If no paths are selected, the entire volume will be
|
||||
backed up.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VolumeFileBrowser
|
||||
volumeName={volume.name}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
withCheckboxes={true}
|
||||
foldersOnly={true}
|
||||
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
||||
/>
|
||||
{selectedPaths.size > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">Selected paths:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from(selectedPaths).map((path) => (
|
||||
<span key={path} className="text-xs bg-accent px-2 py-1 rounded-md font-mono">
|
||||
{path}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exclude patterns</CardTitle>
|
||||
<CardDescription>
|
||||
Optionally specify patterns to exclude from backups. Enter one pattern per line (e.g., *.tmp,
|
||||
node_modules/**, .cache/).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="excludePatternsText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Exclusion patterns</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="*.tmp node_modules/** .cache/ *.log"
|
||||
className="font-mono text-sm min-h-[120px]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Patterns support glob syntax. See
|
||||
<a
|
||||
href="https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
Restic documentation
|
||||
</a>
|
||||
for more details.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Retention policy</CardTitle>
|
||||
<CardDescription>Define how many snapshots to keep. Leave empty to keep all.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLast"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep last N snapshots</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Optional"
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the N most recent snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepHourly"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep hourly</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Optional"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepDaily"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep daily</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="e.g., 7"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N daily snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepWeekly"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep weekly</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="e.g., 4"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepMonthly"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep monthly</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="e.g., 6"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepYearly"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Keep yearly</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Optional"
|
||||
{...field}
|
||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Schedule summary</CardTitle>
|
||||
<CardDescription>Review the backup configuration.</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Volume</p>
|
||||
<p className="font-medium">{volume.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
|
||||
<p className="font-medium">
|
||||
{frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Repository</p>
|
||||
<p className="font-medium">
|
||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{formValues.includePatterns.map((path) => (
|
||||
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{path}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{formValues.excludePatternsText && (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{formValues.excludePatternsText
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((pattern) => (
|
||||
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||
{pattern.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
172
app/client/modules/backups/components/schedule-summary.tsx
Normal file
172
app/client/modules/backups/components/schedule-summary.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Pencil, Play, Square, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OnOff } from "~/client/components/onoff";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import type { BackupSchedule } from "~/client/lib/types";
|
||||
import { BackupProgressCard } from "./backup-progress-card";
|
||||
|
||||
type Props = {
|
||||
schedule: BackupSchedule;
|
||||
handleToggleEnabled: (enabled: boolean) => void;
|
||||
handleRunBackupNow: () => void;
|
||||
handleStopBackup: () => void;
|
||||
handleDeleteSchedule: () => void;
|
||||
setIsEditMode: (isEdit: boolean) => void;
|
||||
};
|
||||
|
||||
export const ScheduleSummary = (props: Props) => {
|
||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||
props;
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
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: schedule.volume.name,
|
||||
scheduleLabel,
|
||||
repositoryLabel: schedule.repositoryId || "No repository selected",
|
||||
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
|
||||
};
|
||||
}, [schedule, schedule.volume.name]);
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
handleDeleteSchedule();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Backup schedule</CardTitle>
|
||||
<CardDescription>
|
||||
Automated backup configuration for volume
|
||||
<strong className="text-strong-accent">{schedule.volume.name}</strong>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-between sm:justify-start">
|
||||
<OnOff
|
||||
isOn={schedule.enabled}
|
||||
toggle={handleToggleEnabled}
|
||||
enabledLabel="Enabled"
|
||||
disabledLabel="Paused"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
{schedule.lastBackupStatus === "in_progress" ? (
|
||||
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Stop backup</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Backup now</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Edit schedule</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-destructive hover:text-destructive w-full sm:w-auto"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Delete</span>
|
||||
</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">{schedule.repository.name}</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 === "in_progress" && "⟳ in progress..."}
|
||||
{!schedule.lastBackupStatus && "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{schedule.lastBackupError && (
|
||||
<div className="md:col-span-2 lg:col-span-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
|
||||
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap break-all">{schedule.lastBackupError}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete backup schedule?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this backup schedule for <strong>{schedule.volume.name}</strong>? This
|
||||
action cannot be undone. Existing snapshots will not be deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete schedule
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
288
app/client/modules/backups/components/snapshot-file-browser.tsx
Normal file
288
app/client/modules/backups/components/snapshot-file-browser.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { FileTree, type FileEntry } from "~/client/components/file-tree";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import type { Snapshot, Volume } from "~/client/lib/types";
|
||||
import { toast } from "sonner";
|
||||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
interface Props {
|
||||
snapshot: Snapshot;
|
||||
repositoryName: string;
|
||||
volume?: Volume;
|
||||
}
|
||||
|
||||
export const SnapshotFileBrowser = (props: Props) => {
|
||||
const { snapshot, repositoryName, volume } = props;
|
||||
|
||||
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set());
|
||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
||||
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||
|
||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
||||
|
||||
const { data: filesData, isLoading: filesLoading } = useQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path: volumeBasePath },
|
||||
}),
|
||||
});
|
||||
|
||||
const stripBasePath = useCallback(
|
||||
(path: string): string => {
|
||||
if (!volumeBasePath) return path;
|
||||
if (path === volumeBasePath) return "/";
|
||||
if (path.startsWith(`${volumeBasePath}/`)) {
|
||||
const stripped = path.slice(volumeBasePath.length);
|
||||
return stripped;
|
||||
}
|
||||
return path;
|
||||
},
|
||||
[volumeBasePath],
|
||||
);
|
||||
|
||||
const addBasePath = useCallback(
|
||||
(displayPath: string): string => {
|
||||
if (!volumeBasePath) return displayPath;
|
||||
if (displayPath === "/") return volumeBasePath;
|
||||
return `${volumeBasePath}${displayPath}`;
|
||||
},
|
||||
[volumeBasePath],
|
||||
);
|
||||
|
||||
useMemo(() => {
|
||||
if (filesData?.files) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of filesData.files) {
|
||||
const strippedPath = stripBasePath(file.path);
|
||||
if (strippedPath !== "/") {
|
||||
next.set(strippedPath, { ...file, path: strippedPath });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setFetchedFolders((prev) => new Set(prev).add("/"));
|
||||
}
|
||||
}, [filesData, stripBasePath]);
|
||||
|
||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||
|
||||
const handleFolderExpand = useCallback(
|
||||
async (folderPath: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(folderPath);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!fetchedFolders.has(folderPath)) {
|
||||
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||
|
||||
try {
|
||||
const fullPath = addBasePath(folderPath);
|
||||
|
||||
const result = await queryClient.ensureQueryData(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path: fullPath },
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.files) {
|
||||
setAllFiles((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const file of result.files) {
|
||||
const strippedPath = stripBasePath(file.path);
|
||||
// Skip the directory itself
|
||||
if (strippedPath !== folderPath) {
|
||||
next.set(strippedPath, { ...file, path: strippedPath });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folder contents:", error);
|
||||
} finally {
|
||||
setLoadingFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(folderPath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath],
|
||||
);
|
||||
|
||||
const handleFolderHover = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||
const fullPath = addBasePath(folderPath);
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
query: { path: fullPath },
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath],
|
||||
);
|
||||
|
||||
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
||||
...restoreSnapshotMutation(),
|
||||
onSuccess: (data) => {
|
||||
toast.success("Restore completed", {
|
||||
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
|
||||
});
|
||||
setSelectedPaths(new Set());
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleRestoreClick = useCallback(() => {
|
||||
setShowRestoreDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRestore = useCallback(() => {
|
||||
const pathsArray = Array.from(selectedPaths);
|
||||
const includePaths = pathsArray.map((path) => addBasePath(path));
|
||||
|
||||
restoreSnapshot({
|
||||
path: { name: repositoryName },
|
||||
body: {
|
||||
snapshotId: snapshot.short_id,
|
||||
include: includePaths,
|
||||
delete: deleteExtraFiles,
|
||||
},
|
||||
});
|
||||
|
||||
setShowRestoreDialog(false);
|
||||
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="h-[600px] flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>File Browser</CardTitle>
|
||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||
</div>
|
||||
{selectedPaths.size > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={isReadOnly ? 0 : undefined}>
|
||||
<Button
|
||||
onClick={handleRestoreClick}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isRestoring || isReadOnly}
|
||||
>
|
||||
{isRestoring
|
||||
? "Restoring..."
|
||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isReadOnly && (
|
||||
<TooltipContent className="text-center">
|
||||
<p>Volume is mounted as read-only.</p>
|
||||
<p>Please remount with read-only disabled to restore files.</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
{filesLoading && fileArray.length === 0 && (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileArray.length === 0 && !filesLoading && (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
||||
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No files in this snapshot</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileArray.length > 0 && (
|
||||
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
||||
<FileTree
|
||||
files={fileArray}
|
||||
onFolderExpand={handleFolderExpand}
|
||||
onFolderHover={handleFolderHover}
|
||||
expandedFolders={expandedFolders}
|
||||
loadingFolders={loadingFolders}
|
||||
className="px-2 py-2"
|
||||
withCheckboxes={true}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={setSelectedPaths}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={showRestoreDialog} onOpenChange={setShowRestoreDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Restore</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{selectedPaths.size > 0
|
||||
? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.`
|
||||
: "This will restore everything from the snapshot."}{" "}
|
||||
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex items-center space-x-2 py-4">
|
||||
<Checkbox
|
||||
id="delete-extra"
|
||||
checked={deleteExtraFiles}
|
||||
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
|
||||
Delete files not present in the snapshot?
|
||||
</Label>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmRestore}>Confirm</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
108
app/client/modules/backups/components/snapshot-timeline.tsx
Normal file
108
app/client/modules/backups/components/snapshot-timeline.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { useEffect } from "react";
|
||||
import type { ListSnapshotsResponse } from "~/client/api-client";
|
||||
|
||||
interface Props {
|
||||
snapshots: ListSnapshotsResponse;
|
||||
snapshotId?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onSnapshotSelect: (snapshotId: string) => void;
|
||||
}
|
||||
|
||||
export const SnapshotTimeline = (props: Props) => {
|
||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshotId && snapshots.length > 0) {
|
||||
onSnapshotSelect(snapshots[snapshots.length - 1].short_id);
|
||||
}
|
||||
}, [snapshotId, snapshots, onSnapshotSelect]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-24 p-4 text-center">
|
||||
<p className="text-destructive">Error loading snapshots: {error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<p className="text-muted-foreground">Loading snapshots...</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<p className="text-muted-foreground">No snapshots available</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-0 pt-2">
|
||||
<div className="w-full bg-card">
|
||||
<div className="relative flex items-center">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
|
||||
{snapshots.map((snapshot, index) => {
|
||||
const date = new Date(snapshot.time);
|
||||
const isSelected = snapshotId === snapshot.short_id;
|
||||
const isLatest = index === snapshots.length - 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={snapshot.short_id}
|
||||
onClick={() => onSnapshotSelect(snapshot.short_id)}
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col items-center gap-2 p-3 rounded-lg transition-all",
|
||||
"border-2 cursor-pointer",
|
||||
{
|
||||
"border-primary bg-primary/10 shadow-md": isSelected,
|
||||
"border-border hover:border-accent hover:bg-accent/5": !isSelected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="text-xs font-semibold text-foreground">
|
||||
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground opacity-75">
|
||||
<ByteSize bytes={snapshot.size} />
|
||||
</div>
|
||||
{isLatest && (
|
||||
<div className="text-xs font-semibold text-primary px-2 py-0.5 bg-primary/20 rounded">Latest</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 text-xs text-muted-foreground bg-card-header border-t border-border flex justify-between">
|
||||
<span>{snapshots.length} snapshots</span>
|
||||
<span>
|
||||
{new Date(snapshots[0].time).toLocaleDateString()} -{" "}
|
||||
{new Date(snapshots.at(-1)?.time ?? 0).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
||||
import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
snapshotId: string;
|
||||
};
|
||||
|
||||
export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
const restore = useMutation({
|
||||
...restoreSnapshotMutation(),
|
||||
onSuccess: (data) => {
|
||||
toast.success("Snapshot restored successfully", {
|
||||
description: `${data.filesRestored} files restored, ${data.filesSkipped} files skipped`,
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to restore snapshot", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: RestoreSnapshotFormValues) => {
|
||||
const include = values.include
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const exclude = values.exclude
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
restore.mutate({
|
||||
path: { name },
|
||||
body: {
|
||||
snapshotId,
|
||||
include: include && include.length > 0 ? include : undefined,
|
||||
exclude: exclude && exclude.length > 0 ? exclude : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<RotateCcw size={16} className="mr-2" />
|
||||
Restore
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<ScrollArea className="max-h-[600px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restore Snapshot</DialogTitle>
|
||||
<DialogDescription>
|
||||
Restore snapshot {snapshotId.substring(0, 8)} to a local filesystem path
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RestoreSnapshotForm className="mt-4" formId={formId} onSubmit={handleSubmit} />
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={restore.isPending}>
|
||||
{restore.isPending ? "Restoring..." : "Restore"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
|
||||
const restoreSnapshotFormSchema = type({
|
||||
path: "string?",
|
||||
include: "string?",
|
||||
exclude: "string?",
|
||||
});
|
||||
|
||||
export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn;
|
||||
|
||||
type Props = {
|
||||
formId: string;
|
||||
onSubmit: (values: RestoreSnapshotFormValues) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
|
||||
const form = useForm<RestoreSnapshotFormValues>({
|
||||
resolver: arktypeResolver(restoreSnapshotFormSchema),
|
||||
defaultValues: {
|
||||
path: "",
|
||||
include: "",
|
||||
exclude: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: RestoreSnapshotFormValues) => {
|
||||
onSubmit(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(handleSubmit)} className={className}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/specific/path" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Restore only a specific path from the snapshot (leave empty to restore all)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="include"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Include Patterns (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="*.txt,/documents/**" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Include only files matching these patterns (comma-separated)</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="exclude"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Exclude Patterns (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="*.log,/temp/**" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Exclude files matching these patterns (comma-separated)</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
190
app/client/modules/repositories/routes/repositories.tsx
Normal file
190
app/client/modules/repositories/routes/repositories.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { listRepositories } from "~/client/api-client/sdk.gen";
|
||||
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { CreateRepositoryDialog } from "~/client/components/create-repository-dialog";
|
||||
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import type { Route } from "./+types/repositories";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Repositories" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your backup repositories with encryption and compression.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const repositories = await listRepositories();
|
||||
if (repositories.data) return repositories.data;
|
||||
return [];
|
||||
};
|
||||
|
||||
export default function Repositories({ loaderData }: Route.ComponentProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("");
|
||||
setBackendFilter("");
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data } = useQuery({
|
||||
...listRepositoriesOptions(),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const filteredRepositories =
|
||||
data?.filter((repository) => {
|
||||
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = !statusFilter || repository.status === statusFilter;
|
||||
const matchesBackend = !backendFilter || repository.type === backendFilter;
|
||||
return matchesSearch && matchesStatus && matchesBackend;
|
||||
}) || [];
|
||||
|
||||
const hasNoRepositories = data?.length === 0;
|
||||
const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories;
|
||||
|
||||
if (hasNoRepositories) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No repository"
|
||||
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
|
||||
button={<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
placeholder="Search repositories…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="healthy">Healthy</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="unknown">Unknown</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
<SelectItem value="sftp">SFTP</SelectItem>
|
||||
<SelectItem value="s3">S3</SelectItem>
|
||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||
<TableHead className="uppercase hidden sm:table-cell">Compression</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hasNoFilteredRepositories ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No repositories match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRepositories.map((repository) => (
|
||||
<TableRow
|
||||
key={repository.name}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/repositories/${repository.name}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{repository.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-2">
|
||||
<RepositoryIcon backend={repository.type} />
|
||||
{repository.type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
|
||||
{repository.compressionMode || "off"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
|
||||
{
|
||||
"bg-green-500/10 text-green-500": repository.status === "healthy",
|
||||
"bg-red-500/10 text-red-500": repository.status === "error",
|
||||
},
|
||||
)}
|
||||
>
|
||||
{repository.status || "unknown"}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
|
||||
{hasNoFilteredRepositories ? (
|
||||
"No repositories match filters."
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-strong-accent">{filteredRepositories.length}</span> repositor
|
||||
{filteredRepositories.length === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
234
app/client/modules/repositories/routes/repository-details.tsx
Normal file
234
app/client/modules/repositories/routes/repository-details.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { redirect, useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
deleteRepositoryMutation,
|
||||
doctorRepositoryMutation,
|
||||
getRepositoryOptions,
|
||||
listSnapshotsOptions,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { getRepository } from "~/client/api-client/sdk.gen";
|
||||
import type { Route } from "./+types/repository-details";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import { RepositoryInfoTabContent } from "../tabs/info";
|
||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "View repository configuration, status, and snapshots.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const repository = await getRepository({ path: { name: params.name ?? "" } });
|
||||
if (repository.data) return repository.data;
|
||||
|
||||
return redirect("/repositories");
|
||||
};
|
||||
|
||||
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "info";
|
||||
|
||||
const { data } = useQuery({
|
||||
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
|
||||
}, [queryClient, data.name]);
|
||||
|
||||
const deleteRepo = useMutation({
|
||||
...deleteRepositoryMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Repository deleted successfully");
|
||||
navigate("/repositories");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete repository", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const doctorMutation = useMutation({
|
||||
...doctorRepositoryMutation(),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setShowDoctorResults(true);
|
||||
|
||||
if (data.success) {
|
||||
toast.success("Repository doctor completed successfully");
|
||||
} else {
|
||||
toast.warning("Doctor completed with some issues", {
|
||||
description: "Check the details for more information",
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to run doctor", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteRepo.mutate({ path: { name: data.name } });
|
||||
};
|
||||
|
||||
const getStepLabel = (step: string) => {
|
||||
switch (step) {
|
||||
case "unlock":
|
||||
return "Unlock Repository";
|
||||
case "check":
|
||||
return "Check Repository";
|
||||
case "repair_index":
|
||||
return "Repair Index";
|
||||
case "recheck":
|
||||
return "Re-check Repository";
|
||||
default:
|
||||
return step;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
||||
"bg-green-500/10 text-green-500": data.status === "healthy",
|
||||
"bg-red-500/10 text-red-500": data.status === "error",
|
||||
})}
|
||||
>
|
||||
{data.status || "unknown"}
|
||||
</span>
|
||||
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => doctorMutation.mutate({ path: { name: data.name } })}
|
||||
disabled={doctorMutation.isPending}
|
||||
variant={"outline"}
|
||||
>
|
||||
{doctorMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Running Doctor...
|
||||
</>
|
||||
) : (
|
||||
"Run Doctor"
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })}>
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">Snapshots</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<RepositoryInfoTabContent repository={data} />
|
||||
</TabsContent>
|
||||
<TabsContent value="snapshots">
|
||||
<RepositorySnapshotsTabContent repository={data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||
and will remove all backup data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete repository
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
||||
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{doctorMutation.data && (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{doctorMutation.data.steps.map((step) => (
|
||||
<div
|
||||
key={step.step}
|
||||
className={cn("border rounded-md p-3", {
|
||||
"bg-green-500/10 border-green-500/20": step.success,
|
||||
"bg-yellow-500/10 border-yellow-500/20": !step.success,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
|
||||
<span
|
||||
className={cn("text-xs px-2 py-1 rounded", {
|
||||
"bg-green-500/20 text-green-500": step.success,
|
||||
"bg-yellow-500/20 text-yellow-500": !step.success,
|
||||
})}
|
||||
>
|
||||
{step.success ? "Success" : "Warning"}
|
||||
</span>
|
||||
</div>
|
||||
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
app/client/modules/repositories/routes/snapshot-details.tsx
Normal file
102
app/client/modules/repositories/routes/snapshot-details.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { redirect, useParams } from "react-router";
|
||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
|
||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||
import { getSnapshotDetails } from "~/client/api-client";
|
||||
import type { Route } from "./+types/snapshot-details";
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Snapshot ${params.snapshotId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Browse and restore files from a backup snapshot.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const snapshot = await getSnapshotDetails({
|
||||
path: { name: params.name, snapshotId: params.snapshotId },
|
||||
});
|
||||
if (snapshot.data) return snapshot.data;
|
||||
|
||||
return redirect("/repositories");
|
||||
};
|
||||
|
||||
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { name, snapshotId } = useParams<{
|
||||
name: string;
|
||||
snapshotId: string;
|
||||
}>();
|
||||
|
||||
const { data } = useQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { name: name ?? "", snapshotId: snapshotId ?? "" },
|
||||
query: { path: "/" },
|
||||
}),
|
||||
enabled: !!name && !!snapshotId,
|
||||
});
|
||||
|
||||
if (!name || !snapshotId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-destructive">Invalid snapshot reference</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
|
||||
</div>
|
||||
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
|
||||
</div>
|
||||
|
||||
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
|
||||
|
||||
{data?.snapshot && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Snapshot Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Snapshot ID:</span>
|
||||
<p className="font-mono break-all">{data.snapshot.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Short ID:</span>
|
||||
<p className="font-mono break-all">{data.snapshot.short_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Hostname:</span>
|
||||
<p>{data.snapshot.hostname}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Time:</span>
|
||||
<p>{new Date(data.snapshot.time).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Paths:</span>
|
||||
<div className="space-y-1 mt-1">
|
||||
{data.snapshot.paths.map((path) => (
|
||||
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
|
||||
{path}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
app/client/modules/repositories/tabs/info.tsx
Normal file
63
app/client/modules/repositories/tabs/info.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import type { Repository } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
};
|
||||
|
||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
||||
<p className="mt-1 text-sm">{repository.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||
<p className="mt-1 text-sm">{repository.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
|
||||
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Created At</div>
|
||||
<p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||
<p className="mt-1 text-sm">
|
||||
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{repository.lastError && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||
<div className="bg-muted/50 rounded-md p-4">
|
||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
148
app/client/modules/repositories/tabs/snapshots.tsx
Normal file
148
app/client/modules/repositories/tabs/snapshots.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Database } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { SnapshotsTable } from "~/client/components/snapshots-table";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableRow } from "~/client/components/ui/table";
|
||||
import type { Repository, Snapshot } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
};
|
||||
|
||||
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { data, isFetching, failureReason } = useQuery({
|
||||
...listSnapshotsOptions({ path: { name: repository.name } }),
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
|
||||
if (!searchQuery) return true;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
snapshot.short_id.toLowerCase().includes(searchLower) ||
|
||||
snapshot.paths.some((path) => path.toLowerCase().includes(searchLower))
|
||||
);
|
||||
});
|
||||
|
||||
const hasNoFilteredSnapshots = !filteredSnapshots?.length;
|
||||
|
||||
if (repository.status === "error") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center text-center py-12">
|
||||
<Database className="mb-4 h-12 w-12 text-destructive" />
|
||||
<p className="text-destructive font-semibold">Repository Error</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This repository is in an error state and cannot be accessed.
|
||||
</p>
|
||||
{repository.lastError && (
|
||||
<div className="mt-4 max-w-md bg-destructive/10 border border-destructive/20 rounded-md p-3">
|
||||
<p className="text-sm text-destructive">{repository.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (failureReason) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center text-center py-12">
|
||||
<Database className="mb-4 h-12 w-12 text-destructive" />
|
||||
<p className="text-destructive font-semibold">Failed to Load Snapshots</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">{failureReason.message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetching && !data.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Loading snapshots</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<Card>
|
||||
<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-linear-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. Create your first backup to see it here.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 stored in this repository. Total: {data.length}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
className="w-full lg:w-60"
|
||||
placeholder="Search snapshots..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{hasNoFilteredSnapshots ? (
|
||||
<Table className="border-t">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No snapshots match your search.</p>
|
||||
<Button onClick={() => setSearchQuery("")} variant="outline" size="sm">
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} />
|
||||
)}
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
|
||||
<span>
|
||||
{hasNoFilteredSnapshots
|
||||
? "No snapshots match filters."
|
||||
: `Showing ${filteredSnapshots.length} of ${data.length}`}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
263
app/client/modules/settings/routes/settings.tsx
Normal file
263
app/client/modules/settings/routes/settings.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Download, KeyRound, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { appContext } from "~/context";
|
||||
import type { Route } from "./+types/settings";
|
||||
import {
|
||||
changePasswordMutation,
|
||||
downloadResticPasswordMutation,
|
||||
logoutMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Settings" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your account settings and preferences.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||
const ctx = context.get(appContext);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||
const [downloadPassword, setDownloadPassword] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = useMutation({
|
||||
...logoutMutation(),
|
||||
onSuccess: () => {
|
||||
navigate("/login", { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const changePassword = useMutation({
|
||||
...changePasswordMutation(),
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success("Password changed successfully. You will be logged out.");
|
||||
setTimeout(() => {
|
||||
logout.mutate({});
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error("Failed to change password", { description: data.message });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to change password", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const downloadResticPassword = useMutation({
|
||||
...downloadResticPasswordMutation(),
|
||||
onSuccess: (data) => {
|
||||
const blob = new Blob([data], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "restic.pass";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Restic password file downloaded successfully");
|
||||
setDownloadDialogOpen(false);
|
||||
setDownloadPassword("");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to download Restic password", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
toast.error("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
changePassword.mutate({
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadResticPassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!downloadPassword) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
downloadResticPassword.mutate({
|
||||
body: {
|
||||
password: downloadPassword,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="border-b border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-5" />
|
||||
Account Information
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Your account details</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-5" />
|
||||
Change Password
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6">
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="max-w-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="max-w-md"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="max-w-md"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={changePassword.isPending} className="mt-4">
|
||||
Change Password
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="size-5" />
|
||||
Backup Recovery Key
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Download your Restic password file for disaster recovery</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
This file contains the encryption password used by Restic to secure your backups. Store it in a safe place
|
||||
(like a password manager or encrypted storage). If you lose access to this server, you'll need this file to
|
||||
recover your backup data.
|
||||
</p>
|
||||
|
||||
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
Download Restic Password
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleDownloadResticPassword}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Download Restic Password</DialogTitle>
|
||||
<DialogDescription>
|
||||
For security reasons, please enter your account password to download the Restic password file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-password">Your Password</Label>
|
||||
<Input
|
||||
id="download-password"
|
||||
type="password"
|
||||
value={downloadPassword}
|
||||
onChange={(e) => setDownloadPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDownloadDialogOpen(false);
|
||||
setDownloadPassword("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={downloadResticPassword.isPending}>
|
||||
Download
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
90
app/client/modules/volumes/components/healthchecks-card.tsx
Normal file
90
app/client/modules/volumes/components/healthchecks-card.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { HeartIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { OnOff } from "~/client/components/onoff";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
};
|
||||
|
||||
export const HealthchecksCard = ({ volume }: Props) => {
|
||||
const timeAgo = formatDistanceToNow(volume.lastHealthCheck, {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
const healthcheck = useMutation({
|
||||
...healthCheckVolumeMutation(),
|
||||
onSuccess: (d) => {
|
||||
if (d.error) {
|
||||
toast.error("Health check failed", { description: d.error });
|
||||
return;
|
||||
}
|
||||
toast.success("Health check completed", { description: "The volume is healthy." });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Health check failed", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleAutoRemount = useMutation({
|
||||
...updateVolumeMutation(),
|
||||
onSuccess: (d) => {
|
||||
toast.success("Volume updated", {
|
||||
description: `Auto remount is now ${d.autoRemount ? "enabled" : "paused"}.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Update failed", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="flex-1 flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HeartIcon className="h-4 w-4" />
|
||||
Health Checks
|
||||
</CardTitle>
|
||||
<CardDescription>Monitor and automatically remount volumes on errors to ensure availability.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col flex-1 justify-start">
|
||||
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
|
||||
{volume.status === "mounted" && <span className="text-md text-emerald-500">Healthy</span>}
|
||||
{volume.status !== "unmounted" && (
|
||||
<span className="text-xs text-muted-foreground mb-4">Checked {timeAgo || "never"}</span>
|
||||
)}
|
||||
<span className="flex justify-between items-center gap-2">
|
||||
<span className="text-sm">Remount on error</span>
|
||||
<OnOff
|
||||
isOn={volume.autoRemount}
|
||||
toggle={() =>
|
||||
toggleAutoRemount.mutate({ path: { name: volume.name }, body: { autoRemount: !volume.autoRemount } })
|
||||
}
|
||||
disabled={toggleAutoRemount.isPending}
|
||||
enabledLabel="Enabled"
|
||||
disabledLabel="Paused"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{volume.status !== "unmounted" && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
loading={healthcheck.isPending}
|
||||
onClick={() => healthcheck.mutate({ path: { name: volume.name } })}
|
||||
>
|
||||
Run Health Check
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
133
app/client/modules/volumes/components/storage-chart.tsx
Normal file
133
app/client/modules/volumes/components/storage-chart.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { HardDrive, Unplug } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Label, Pie, PieChart } from "recharts";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/client/components/ui/chart";
|
||||
import type { StatFs } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
statfs: StatFs;
|
||||
};
|
||||
|
||||
export function StorageChart({ statfs }: Props) {
|
||||
const chartData = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "Used",
|
||||
value: statfs.used,
|
||||
fill: "#ff543a",
|
||||
},
|
||||
{
|
||||
name: "Free",
|
||||
value: statfs.free,
|
||||
fill: "lightgray",
|
||||
},
|
||||
],
|
||||
[statfs],
|
||||
);
|
||||
|
||||
const chartConfig = {} satisfies ChartConfig;
|
||||
|
||||
const usagePercentage = React.useMemo(() => {
|
||||
return Math.round((statfs.used / statfs.total) * 100);
|
||||
}, [statfs]);
|
||||
|
||||
const isEmpty = !statfs.total;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<Card className="flex flex-col h-full text-sm">
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Storage Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-10 flex flex-col items-center justify-center text-center">
|
||||
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No storage data available. Mount the volume to see usage statistics.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-full text-sm">
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Storage Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
<div>
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
formatter={(value, name) => [<ByteSize key={name} bytes={value as number} />, name]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie data={chartData} dataKey="value" nameKey="name" innerRadius={60} strokeWidth={5}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold">
|
||||
{usagePercentage}%
|
||||
</tspan>
|
||||
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 24} className="fill-muted-foreground">
|
||||
Used
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="flex flex-col h-full justify-center">
|
||||
<div className="grid gap-4 w-full">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Total Capacity</span>
|
||||
</div>
|
||||
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-strong-accent/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-4 w-4 rounded-full bg-strong-accent" />
|
||||
<span className="font-medium">Used Space</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-4 w-4 rounded-full bg-primary" />
|
||||
<span className="font-medium">Free Space</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
197
app/client/modules/volumes/routes/volume-details.tsx
Normal file
197
app/client/modules/volumes/routes/volume-details.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import type { Route } from "./+types/volume-details";
|
||||
import { VolumeInfoTabContent } from "../tabs/info";
|
||||
import { FilesTabContent } from "../tabs/files";
|
||||
import { DockerTabContent } from "../tabs/docker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||
import { getVolume } from "~/client/api-client";
|
||||
import {
|
||||
deleteVolumeMutation,
|
||||
getVolumeOptions,
|
||||
mountVolumeMutation,
|
||||
unmountVolumeMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const volume = await getVolume({ path: { name: params.name } });
|
||||
if (volume.data) return volume.data;
|
||||
};
|
||||
|
||||
export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "info";
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
...getVolumeOptions({ path: { name: name ?? "" } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
|
||||
const deleteVol = useMutation({
|
||||
...deleteVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume deleted successfully");
|
||||
navigate("/volumes");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mountVol = useMutation({
|
||||
...mountVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume mounted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to mount volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const unmountVol = useMutation({
|
||||
...unmountVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume unmounted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to unmount volume", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteVol.mutate({ path: { name: name ?? "" } });
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return <div>Volume not found</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const { volume, statfs } = data;
|
||||
const dockerAvailable = capabilities.docker;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => mountVol.mutate({ path: { name } })}
|
||||
loading={mountVol.isPending}
|
||||
className={cn({ hidden: volume.status === "mounted" })}
|
||||
>
|
||||
Mount
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => unmountVol.mutate({ path: { name } })}
|
||||
loading={unmountVol.isPending}
|
||||
className={cn({ hidden: volume.status !== "mounted" })}
|
||||
>
|
||||
Unmount
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteVol.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TabsTrigger disabled={!dockerAvailable} value="docker">
|
||||
Docker
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: dockerAvailable })}>
|
||||
<p>Enable Docker support to access this tab.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="files">
|
||||
<FilesTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
{dockerAvailable && (
|
||||
<TabsContent value="docker">
|
||||
<DockerTabContent volume={volume} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete volume?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the volume <strong>{name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete volume
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
170
app/client/modules/volumes/routes/volumes.tsx
Normal file
170
app/client/modules/volumes/routes/volumes.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HardDrive, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { CreateVolumeDialog } from "~/client/components/create-volume-dialog";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import type { Route } from "./+types/volumes";
|
||||
import { listVolumes } from "~/client/api-client";
|
||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Volumes" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const volumes = await listVolumes();
|
||||
if (volumes.data) return volumes.data;
|
||||
return [];
|
||||
};
|
||||
|
||||
export default function Volumes({ loaderData }: Route.ComponentProps) {
|
||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [backendFilter, setBackendFilter] = useState("");
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("");
|
||||
setBackendFilter("");
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data } = useQuery({
|
||||
...listVolumesOptions(),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const filteredVolumes =
|
||||
data.filter((volume) => {
|
||||
const matchesSearch = volume.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = !statusFilter || volume.status === statusFilter;
|
||||
const matchesBackend = !backendFilter || volume.type === backendFilter;
|
||||
return matchesSearch && matchesStatus && matchesBackend;
|
||||
}) || [];
|
||||
|
||||
const hasNoVolumes = data.length === 0;
|
||||
const hasNoFilteredVolumes = filteredVolumes.length === 0 && !hasNoVolumes;
|
||||
|
||||
if (hasNoVolumes) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={HardDrive}
|
||||
title="No volume"
|
||||
description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks."
|
||||
button={<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
placeholder="Search volumes…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mounted">Mounted</SelectItem>
|
||||
<SelectItem value="unmounted">Unmounted</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(searchQuery || statusFilter || backendFilter) && (
|
||||
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="border-t">
|
||||
<TableHeader className="bg-card-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hasNoFilteredVolumes ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No volumes match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredVolumes.map((volume) => (
|
||||
<TableRow
|
||||
key={volume.name}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/volumes/${volume.name}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
|
||||
<TableCell>
|
||||
<VolumeIcon backend={volume.type} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<StatusDot status={volume.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
|
||||
{hasNoFilteredVolumes ? (
|
||||
"No volumes match filters."
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
|
||||
{filteredVolumes.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
133
app/client/modules/volumes/tabs/docker.tsx
Normal file
133
app/client/modules/volumes/tabs/docker.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Unplug } from "lucide-react";
|
||||
import * as YML from "yaml";
|
||||
import { getContainersUsingVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { CodeBlock } from "~/client/components/ui/code-block";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
};
|
||||
|
||||
export const DockerTabContent = ({ volume }: Props) => {
|
||||
const yamlString = YML.stringify({
|
||||
services: {
|
||||
nginx: {
|
||||
image: "nginx:latest",
|
||||
volumes: [`im-${volume.name}:/path/in/container`],
|
||||
},
|
||||
},
|
||||
volumes: {
|
||||
[`im-${volume.name}`]: {
|
||||
external: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`;
|
||||
|
||||
const {
|
||||
data: containersData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
...getContainersUsingVolumeOptions({ path: { name: volume.name } }),
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const containers = containersData || [];
|
||||
|
||||
const getStateClass = (state: string) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "exited":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plug-and-play Docker integration</CardTitle>
|
||||
<CardDescription>
|
||||
This volume can be used in your Docker Compose files by referencing it as an external volume. The example
|
||||
demonstrates how to mount the volume to a service (nginx in this case). Make sure to adjust the path inside
|
||||
the container to fit your application's needs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<CodeBlock code={yamlString} language="yaml" filename="docker-compose.yml" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Alternatively, you can use the following command to run a Docker container with the volume mounted
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<CodeBlock code={dockerRunCommand} filename="CLI one-liner" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Containers Using This Volume</CardTitle>
|
||||
<CardDescription>List of Docker containers mounting this volume.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 text-sm h-full">
|
||||
{isLoading && <div>Loading containers...</div>}
|
||||
{error && <div className="text-destructive">Failed to load containers: {String(error)}</div>}
|
||||
{!isLoading && !error && containers.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center text-center h-full">
|
||||
<Unplug className="mb-4 h-5 w-5 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No Docker containers are currently using this volume.</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && containers.length > 0 && (
|
||||
<div className="max-h-130 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Image</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-sm">
|
||||
{containers.map((container) => (
|
||||
<TableRow key={container.id}>
|
||||
<TableCell>{container.name}</TableCell>
|
||||
<TableCell>{container.id.slice(0, 12)}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStateClass(container.state)}`}
|
||||
>
|
||||
{container.state}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{container.image}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
app/client/modules/volumes/tabs/files.tsx
Normal file
40
app/client/modules/volumes/tabs/files.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
};
|
||||
|
||||
export const FilesTabContent = ({ volume }: Props) => {
|
||||
if (volume.status !== "mounted") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center text-center py-12">
|
||||
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Volume must be mounted to browse files.</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Mount the volume to explore its contents.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-[600px] flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>File Explorer</CardTitle>
|
||||
<CardDescription>Browse the files and folders in this volume.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col">
|
||||
<VolumeFileBrowser
|
||||
volumeName={volume.name}
|
||||
enabled={volume.status === "mounted"}
|
||||
className="overflow-auto flex-1 border rounded-md bg-card p-2"
|
||||
emptyMessage="This volume is empty."
|
||||
emptyDescription="Files and folders will appear here once you add them."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
95
app/client/modules/volumes/tabs/info.tsx
Normal file
95
app/client/modules/volumes/tabs/info.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import type { StatFs, Volume } from "~/client/lib/types";
|
||||
import { HealthchecksCard } from "../components/healthchecks-card";
|
||||
import { StorageChart } from "../components/storage-chart";
|
||||
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
statfs: StatFs;
|
||||
};
|
||||
|
||||
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||
const updateMutation = useMutation({
|
||||
...updateVolumeMutation(),
|
||||
onSuccess: (_) => {
|
||||
toast.success("Volume updated successfully");
|
||||
setOpen(false);
|
||||
setPendingValues(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update volume", { description: error.message });
|
||||
setOpen(false);
|
||||
setPendingValues(null);
|
||||
},
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingValues, setPendingValues] = useState<FormValues | null>(null);
|
||||
|
||||
const handleSubmit = (values: FormValues) => {
|
||||
setPendingValues(values);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const confirmUpdate = () => {
|
||||
if (pendingValues) {
|
||||
updateMutation.mutate({
|
||||
path: { name: volume.name },
|
||||
body: { config: pendingValues },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
|
||||
<Card className="p-6">
|
||||
<CreateVolumeForm
|
||||
initialValues={{ ...volume, ...volume.config }}
|
||||
onSubmit={handleSubmit}
|
||||
mode="update"
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="self-start w-full">
|
||||
<HealthchecksCard volume={volume} />
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<StorageChart statfs={statfs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Update Volume Configuration</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Editing the volume will remount it with the new config immediately. This may temporarily disrupt access to
|
||||
the volume. Continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user