feat: exclude patterns

This commit is contained in:
Nicolas Meienberger
2025-11-09 14:09:49 +01:00
parent dd36397346
commit 2aa90ec44d
3 changed files with 120 additions and 9 deletions

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "~/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -9,13 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/com
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea";
import { VolumeFileBrowser } from "~/components/volume-file-browser"; import { VolumeFileBrowser } from "~/components/volume-file-browser";
import type { BackupSchedule, Volume } from "~/lib/types"; import type { BackupSchedule, Volume } from "~/lib/types";
import { deepClean } from "~/utils/object"; import { deepClean } from "~/utils/object";
const formSchema = type({ const internalFormSchema = type({
repositoryId: "string", repositoryId: "string",
excludePatterns: "string[]?", excludePatternsText: "string?",
includePatterns: "string[]?", includePatterns: "string[]?",
frequency: "string", frequency: "string",
dailyTime: "string?", dailyTime: "string?",
@@ -27,7 +28,7 @@ const formSchema = type({
keepMonthly: "number?", keepMonthly: "number?",
keepYearly: "number?", keepYearly: "number?",
}); });
const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
export const weeklyDays = [ export const weeklyDays = [
{ label: "Monday", value: "1" }, { label: "Monday", value: "1" },
@@ -39,7 +40,11 @@ export const weeklyDays = [
{ label: "Sunday", value: "0" }, { label: "Sunday", value: "0" },
]; ];
export type BackupScheduleFormValues = typeof formSchema.infer; type InternalFormValues = typeof internalFormSchema.infer;
export type BackupScheduleFormValues = Omit<InternalFormValues, "excludePatternsText"> & {
excludePatterns?: string[];
};
type Props = { type Props = {
volume: Volume; volume: Volume;
@@ -50,7 +55,7 @@ type Props = {
formId: string; formId: string;
}; };
const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => { const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
if (!schedule) { if (!schedule) {
return undefined; return undefined;
} }
@@ -72,16 +77,36 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo
dailyTime, dailyTime,
weeklyDay, weeklyDay,
includePatterns: schedule.includePatterns || undefined, includePatterns: schedule.includePatterns || undefined,
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
...schedule.retentionPolicy, ...schedule.retentionPolicy,
}; };
}; };
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => { export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<BackupScheduleFormValues>({ const form = useForm<InternalFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), resolver: arktypeResolver(cleanSchema as unknown as typeof internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues), 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({ const { data: repositoriesData } = useQuery({
...listRepositoriesOptions(), ...listRepositoriesOptions(),
}); });
@@ -102,7 +127,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(handleSubmit)}
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]" className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]"
id={formId} id={formId}
> >
@@ -249,6 +274,47 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</CardContent> </CardContent>
</Card> </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&#10;node_modules/**&#10;.cache/&#10;*.log"
className="font-mono text-sm min-h-[120px]"
/>
</FormControl>
<FormDescription>
Patterns support glob syntax. See&nbsp;
<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>
&nbsp;for more details.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Retention policy</CardTitle> <CardTitle>Retention policy</CardTitle>
@@ -408,6 +474,33 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"} {repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
</p> </p>
</div> </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> <div>
<p className="text-xs uppercase text-muted-foreground">Retention</p> <p className="text-xs uppercase text-muted-foreground">Retention</p>
<p className="font-medium"> <p className="font-medium">

View File

@@ -82,7 +82,7 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
const buildEnv = async (config: RepositoryConfig) => { const buildEnv = async (config: RepositoryConfig) => {
const env: Record<string, string> = { const env: Record<string, string> = {
RESTIC_CACHE_DIR: "/tmp/restic-cache", RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache",
RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE, RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE,
}; };