feat(backups): add custom restic params to backup schedules

Allows users to pass arbitrary restic flags via a new advanced section in the create/edit schedule form. Includes DB migration, schema update, DTO, service, and restic command changes.
This commit is contained in:
bcrooker 2026-02-27 14:46:47 -05:00
parent b2d2f28b40
commit ef51d665c8
15 changed files with 2134 additions and 1 deletions

View file

@ -2202,6 +2202,7 @@ export type ListBackupSchedulesResponses = {
name: string;
nextBackupAt: number | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
repository: {
compressionMode: "auto" | "max" | "off" | null;
config:
@ -2485,6 +2486,7 @@ export type CreateBackupScheduleData = {
excludePatterns?: Array<string>;
includePatterns?: Array<string>;
oneFileSystem?: boolean;
customResticParams?: Array<string>;
retentionPolicy?: {
keepDaily?: number;
keepHourly?: number;
@ -2519,6 +2521,7 @@ export type CreateBackupScheduleResponses = {
name: string;
nextBackupAt: number | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
@ -2584,6 +2587,7 @@ export type GetBackupScheduleResponses = {
name: string;
nextBackupAt: number | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
repository: {
compressionMode: "auto" | "max" | "off" | null;
config:
@ -2866,6 +2870,7 @@ export type UpdateBackupScheduleData = {
includePatterns?: Array<string>;
name?: string;
oneFileSystem?: boolean;
customResticParams?: Array<string>;
retentionPolicy?: {
keepDaily?: number;
keepHourly?: number;
@ -2902,6 +2907,7 @@ export type UpdateBackupScheduleResponses = {
name: string;
nextBackupAt: number | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
@ -2947,6 +2953,7 @@ export type GetBackupScheduleForVolumeResponses = {
name: string;
nextBackupAt: number | null;
oneFileSystem: boolean;
customResticParams: Array<string> | null;
repository: {
compressionMode: "auto" | "max" | "off" | null;
config:

View file

@ -0,0 +1,35 @@
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Textarea } from "~/client/components/ui/textarea";
import type { UseFormReturn } from "react-hook-form";
import type { InternalFormValues } from "./types";
type AdvancedSectionProps = {
form: UseFormReturn<InternalFormValues>;
};
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
return (
<FormField
control={form.control}
name="customResticParamsText"
render={({ field }) => (
<FormItem>
<FormLabel>Custom restic parameters</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="--iexclude-larger-than 500M&#10;--no-scan&#10;--read-concurrency 8"
className="font-mono text-sm min-h-24"
/>
</FormControl>
<FormDescription>
Advanced: enter one restic flag per line (e.g.{" "}
<code className="bg-muted px-1 rounded">--iexclude-larger-than 500M</code>). These are appended to the
backup command as-is.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};

View file

@ -5,6 +5,7 @@ import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { Form } from "~/client/components/ui/form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { BackupSchedule, Volume } from "~/client/lib/types";
import { AdvancedSection } from "./advanced-section";
import { BasicInfoSection } from "./basic-info-section";
import { ExcludeSection } from "./exclude-section";
import { FrequencySection } from "./frequency-section";
@ -39,6 +40,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
excludePatternsText,
excludeIfPresentText,
includePatternsText,
customResticParamsText,
includePatterns: fileBrowserPatterns,
cronExpression,
...rest
@ -65,12 +67,20 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
: [];
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
const customResticParams = customResticParamsText
? customResticParamsText
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
: [];
onSubmit({
...rest,
cronExpression,
includePatterns: includePatterns.length > 0 ? includePatterns : [],
excludePatterns,
excludeIfPresent,
customResticParams,
});
},
[onSubmit],
@ -164,6 +174,18 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<RetentionSection form={form} />
</CardContent>
</Card>
<Card className="min-w-0">
<CardHeader>
<CardTitle>Advanced</CardTitle>
<CardDescription>
Pass additional flags directly to the restic backup command. Use with caution.
</CardDescription>
</CardHeader>
<CardContent>
<AdvancedSection form={form} />
</CardContent>
</Card>
</div>
<div className="xl:sticky xl:top-6 xl:self-start">
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />

View file

@ -20,6 +20,7 @@ export const internalFormSchema = type({
keepMonthly: "number?",
keepYearly: "number?",
oneFileSystem: "boolean?",
customResticParamsText: "string?",
});
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
@ -38,8 +39,9 @@ export type InternalFormValues = typeof internalFormSchema.infer;
export type BackupScheduleFormValues = Omit<
InternalFormValues,
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText"
> & {
excludePatterns?: string[];
excludeIfPresent?: string[];
customResticParams?: string[];
};

View file

@ -22,6 +22,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
oneFileSystem: schedule.oneFileSystem ?? false,
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
...cronValues,
...schedule.retentionPolicy,
};

View file

@ -181,6 +181,7 @@ export function ScheduleDetailsPage(props: Props) {
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
},
});
};

View file

@ -73,6 +73,7 @@ export function CreateBackupPage() {
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
},
});
};

View file

@ -0,0 +1 @@
ALTER TABLE `backup_schedules_table` ADD `custom_restic_params` text DEFAULT '[]';

File diff suppressed because it is too large Load diff

View file

@ -271,6 +271,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "number" }),
oneFileSystem: int("one_file_system", { mode: "boolean" }).notNull().default(false),
customResticParams: text("custom_restic_params", { mode: "json" }).$type<string[]>().default([]),
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
createdAt: int("created_at", { mode: "number" })
.notNull()

View file

@ -38,4 +38,5 @@ export const createBackupOptions = (schedule: BackupSchedule, volumePath: string
exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined,
excludeIfPresent: schedule.excludeIfPresent ?? undefined,
include: schedule.includePatterns ? schedule.includePatterns.map((p) => processPattern(p, volumePath)) : undefined,
customResticParams: schedule.customResticParams ?? undefined,
});

View file

@ -29,6 +29,7 @@ const backupScheduleSchema = type({
excludeIfPresent: "string[] | null",
includePatterns: "string[] | null",
oneFileSystem: "boolean",
customResticParams: "string[] | null",
lastBackupAt: "number | null",
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
lastBackupError: "string | null",
@ -136,6 +137,7 @@ export const createBackupScheduleBody = type({
includePatterns: "string[]?",
oneFileSystem: "boolean?",
tags: "string[]?",
customResticParams: "string[]?",
});
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
@ -174,6 +176,7 @@ export const updateBackupScheduleBody = type({
includePatterns: "string[]?",
oneFileSystem: "boolean?",
tags: "string[]?",
customResticParams: "string[]?",
});
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;

View file

@ -134,6 +134,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
excludeIfPresent: data.excludeIfPresent ?? [],
includePatterns: data.includePatterns ?? [],
oneFileSystem: data.oneFileSystem,
customResticParams: data.customResticParams ?? [],
nextBackupAt: nextBackupAt,
shortId: generateShortId(),
organizationId,

View file

@ -32,6 +32,7 @@ export const backup = async (
compressionMode?: CompressionMode;
signal?: AbortSignal;
onProgress?: (progress: ResticBackupProgressDto) => void;
customResticParams?: string[];
},
) => {
const repoUrl = buildRepoUrl(config);
@ -85,6 +86,13 @@ export const backup = async (
}
}
if (options.customResticParams && options.customResticParams.length > 0) {
for (const param of options.customResticParams) {
const tokens = param.trim().split(/\s+/).filter(Boolean);
args.push(...tokens);
}
}
addCommonArgs(args, env, config);
const logData = throttle((data: string) => {

View file

@ -38,5 +38,9 @@ export default defineConfig({
server: {
host: "0.0.0.0",
port: 3000,
watch: {
usePolling: true,
ignored: ["**/data/**", "**/*.db", "**/*.db-journal", "**/*.db-wal", "**/*.db-shm"],
},
},
});