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:
parent
b2d2f28b40
commit
ef51d665c8
15 changed files with 2134 additions and 1 deletions
|
|
@ -2202,6 +2202,7 @@ export type ListBackupSchedulesResponses = {
|
||||||
name: string;
|
name: string;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
|
customResticParams: Array<string> | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: "auto" | "max" | "off" | null;
|
compressionMode: "auto" | "max" | "off" | null;
|
||||||
config:
|
config:
|
||||||
|
|
@ -2485,6 +2486,7 @@ export type CreateBackupScheduleData = {
|
||||||
excludePatterns?: Array<string>;
|
excludePatterns?: Array<string>;
|
||||||
includePatterns?: Array<string>;
|
includePatterns?: Array<string>;
|
||||||
oneFileSystem?: boolean;
|
oneFileSystem?: boolean;
|
||||||
|
customResticParams?: Array<string>;
|
||||||
retentionPolicy?: {
|
retentionPolicy?: {
|
||||||
keepDaily?: number;
|
keepDaily?: number;
|
||||||
keepHourly?: number;
|
keepHourly?: number;
|
||||||
|
|
@ -2519,6 +2521,7 @@ export type CreateBackupScheduleResponses = {
|
||||||
name: string;
|
name: string;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
|
customResticParams: Array<string> | null;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
keepDaily?: number;
|
keepDaily?: number;
|
||||||
|
|
@ -2584,6 +2587,7 @@ export type GetBackupScheduleResponses = {
|
||||||
name: string;
|
name: string;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
|
customResticParams: Array<string> | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: "auto" | "max" | "off" | null;
|
compressionMode: "auto" | "max" | "off" | null;
|
||||||
config:
|
config:
|
||||||
|
|
@ -2866,6 +2870,7 @@ export type UpdateBackupScheduleData = {
|
||||||
includePatterns?: Array<string>;
|
includePatterns?: Array<string>;
|
||||||
name?: string;
|
name?: string;
|
||||||
oneFileSystem?: boolean;
|
oneFileSystem?: boolean;
|
||||||
|
customResticParams?: Array<string>;
|
||||||
retentionPolicy?: {
|
retentionPolicy?: {
|
||||||
keepDaily?: number;
|
keepDaily?: number;
|
||||||
keepHourly?: number;
|
keepHourly?: number;
|
||||||
|
|
@ -2902,6 +2907,7 @@ export type UpdateBackupScheduleResponses = {
|
||||||
name: string;
|
name: string;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
|
customResticParams: Array<string> | null;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
keepDaily?: number;
|
keepDaily?: number;
|
||||||
|
|
@ -2947,6 +2953,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
name: string;
|
name: string;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
oneFileSystem: boolean;
|
oneFileSystem: boolean;
|
||||||
|
customResticParams: Array<string> | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: "auto" | "max" | "off" | null;
|
compressionMode: "auto" | "max" | "off" | null;
|
||||||
config:
|
config:
|
||||||
|
|
|
||||||
|
|
@ -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 --no-scan --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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@ import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { Form } from "~/client/components/ui/form";
|
import { Form } from "~/client/components/ui/form";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||||
|
import { AdvancedSection } from "./advanced-section";
|
||||||
import { BasicInfoSection } from "./basic-info-section";
|
import { BasicInfoSection } from "./basic-info-section";
|
||||||
import { ExcludeSection } from "./exclude-section";
|
import { ExcludeSection } from "./exclude-section";
|
||||||
import { FrequencySection } from "./frequency-section";
|
import { FrequencySection } from "./frequency-section";
|
||||||
|
|
@ -39,6 +40,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
excludePatternsText,
|
excludePatternsText,
|
||||||
excludeIfPresentText,
|
excludeIfPresentText,
|
||||||
includePatternsText,
|
includePatternsText,
|
||||||
|
customResticParamsText,
|
||||||
includePatterns: fileBrowserPatterns,
|
includePatterns: fileBrowserPatterns,
|
||||||
cronExpression,
|
cronExpression,
|
||||||
...rest
|
...rest
|
||||||
|
|
@ -65,12 +67,20 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
: [];
|
: [];
|
||||||
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
|
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
|
||||||
|
|
||||||
|
const customResticParams = customResticParamsText
|
||||||
|
? customResticParamsText
|
||||||
|
.split("\n")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...rest,
|
...rest,
|
||||||
cronExpression,
|
cronExpression,
|
||||||
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
||||||
excludePatterns,
|
excludePatterns,
|
||||||
excludeIfPresent,
|
excludeIfPresent,
|
||||||
|
customResticParams,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[onSubmit],
|
[onSubmit],
|
||||||
|
|
@ -164,6 +174,18 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
<RetentionSection form={form} />
|
<RetentionSection form={form} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
<div className="xl:sticky xl:top-6 xl:self-start">
|
<div className="xl:sticky xl:top-6 xl:self-start">
|
||||||
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />
|
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export const internalFormSchema = type({
|
||||||
keepMonthly: "number?",
|
keepMonthly: "number?",
|
||||||
keepYearly: "number?",
|
keepYearly: "number?",
|
||||||
oneFileSystem: "boolean?",
|
oneFileSystem: "boolean?",
|
||||||
|
customResticParamsText: "string?",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
||||||
|
|
@ -38,8 +39,9 @@ export type InternalFormValues = typeof internalFormSchema.infer;
|
||||||
|
|
||||||
export type BackupScheduleFormValues = Omit<
|
export type BackupScheduleFormValues = Omit<
|
||||||
InternalFormValues,
|
InternalFormValues,
|
||||||
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
|
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText"
|
||||||
> & {
|
> & {
|
||||||
excludePatterns?: string[];
|
excludePatterns?: string[];
|
||||||
excludeIfPresent?: string[];
|
excludeIfPresent?: string[];
|
||||||
|
customResticParams?: string[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
|
||||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||||
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
||||||
oneFileSystem: schedule.oneFileSystem ?? false,
|
oneFileSystem: schedule.oneFileSystem ?? false,
|
||||||
|
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
|
||||||
...cronValues,
|
...cronValues,
|
||||||
...schedule.retentionPolicy,
|
...schedule.retentionPolicy,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,7 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
excludePatterns: formValues.excludePatterns,
|
excludePatterns: formValues.excludePatterns,
|
||||||
excludeIfPresent: formValues.excludeIfPresent,
|
excludeIfPresent: formValues.excludeIfPresent,
|
||||||
oneFileSystem: formValues.oneFileSystem,
|
oneFileSystem: formValues.oneFileSystem,
|
||||||
|
customResticParams: formValues.customResticParams,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export function CreateBackupPage() {
|
||||||
excludePatterns: formValues.excludePatterns,
|
excludePatterns: formValues.excludePatterns,
|
||||||
excludeIfPresent: formValues.excludeIfPresent,
|
excludeIfPresent: formValues.excludeIfPresent,
|
||||||
oneFileSystem: formValues.oneFileSystem,
|
oneFileSystem: formValues.oneFileSystem,
|
||||||
|
customResticParams: formValues.customResticParams,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `backup_schedules_table` ADD `custom_restic_params` text DEFAULT '[]';
|
||||||
2045
app/drizzle/20260227000000_add-custom-restic-params/snapshot.json
Normal file
2045
app/drizzle/20260227000000_add-custom-restic-params/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -271,6 +271,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||||
lastBackupError: text("last_backup_error"),
|
lastBackupError: text("last_backup_error"),
|
||||||
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
||||||
oneFileSystem: int("one_file_system", { mode: "boolean" }).notNull().default(false),
|
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),
|
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
|
||||||
createdAt: int("created_at", { mode: "number" })
|
createdAt: int("created_at", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,5 @@ export const createBackupOptions = (schedule: BackupSchedule, volumePath: string
|
||||||
exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined,
|
exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined,
|
||||||
excludeIfPresent: schedule.excludeIfPresent ?? undefined,
|
excludeIfPresent: schedule.excludeIfPresent ?? undefined,
|
||||||
include: schedule.includePatterns ? schedule.includePatterns.map((p) => processPattern(p, volumePath)) : undefined,
|
include: schedule.includePatterns ? schedule.includePatterns.map((p) => processPattern(p, volumePath)) : undefined,
|
||||||
|
customResticParams: schedule.customResticParams ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ const backupScheduleSchema = type({
|
||||||
excludeIfPresent: "string[] | null",
|
excludeIfPresent: "string[] | null",
|
||||||
includePatterns: "string[] | null",
|
includePatterns: "string[] | null",
|
||||||
oneFileSystem: "boolean",
|
oneFileSystem: "boolean",
|
||||||
|
customResticParams: "string[] | null",
|
||||||
lastBackupAt: "number | null",
|
lastBackupAt: "number | null",
|
||||||
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
|
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
|
||||||
lastBackupError: "string | null",
|
lastBackupError: "string | null",
|
||||||
|
|
@ -136,6 +137,7 @@ export const createBackupScheduleBody = type({
|
||||||
includePatterns: "string[]?",
|
includePatterns: "string[]?",
|
||||||
oneFileSystem: "boolean?",
|
oneFileSystem: "boolean?",
|
||||||
tags: "string[]?",
|
tags: "string[]?",
|
||||||
|
customResticParams: "string[]?",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
|
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
|
||||||
|
|
@ -174,6 +176,7 @@ export const updateBackupScheduleBody = type({
|
||||||
includePatterns: "string[]?",
|
includePatterns: "string[]?",
|
||||||
oneFileSystem: "boolean?",
|
oneFileSystem: "boolean?",
|
||||||
tags: "string[]?",
|
tags: "string[]?",
|
||||||
|
customResticParams: "string[]?",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
|
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
||||||
excludeIfPresent: data.excludeIfPresent ?? [],
|
excludeIfPresent: data.excludeIfPresent ?? [],
|
||||||
includePatterns: data.includePatterns ?? [],
|
includePatterns: data.includePatterns ?? [],
|
||||||
oneFileSystem: data.oneFileSystem,
|
oneFileSystem: data.oneFileSystem,
|
||||||
|
customResticParams: data.customResticParams ?? [],
|
||||||
nextBackupAt: nextBackupAt,
|
nextBackupAt: nextBackupAt,
|
||||||
shortId: generateShortId(),
|
shortId: generateShortId(),
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const backup = async (
|
||||||
compressionMode?: CompressionMode;
|
compressionMode?: CompressionMode;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onProgress?: (progress: ResticBackupProgressDto) => void;
|
onProgress?: (progress: ResticBackupProgressDto) => void;
|
||||||
|
customResticParams?: string[];
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
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);
|
addCommonArgs(args, env, config);
|
||||||
|
|
||||||
const logData = throttle((data: string) => {
|
const logData = throttle((data: string) => {
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,9 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
ignored: ["**/data/**", "**/*.db", "**/*.db-journal", "**/*.db-wal", "**/*.db-shm"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue