Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PenBoxIcon,
PlusIcon,
RefreshCw,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
Expand Down Expand Up @@ -106,6 +107,8 @@ const Schema = z
.optional(),
})
.optional(),
includePaths: z.array(z.string()).optional(),
excludePaths: z.array(z.string()).optional(),
})
.superRefine((data, ctx) => {
if (data.backupType === "compose" && !data.databaseType) {
Expand Down Expand Up @@ -219,6 +222,8 @@ export const HandleBackup = ({
databaseType: backupType === "compose" ? undefined : databaseType,
backupType: backupType,
metadata: {},
includePaths: undefined,
excludePaths: databaseType === "web-server" ? ["/applications/**", "/compose/**"] : undefined,
},
resolver: zodResolver(Schema),
});
Expand Down Expand Up @@ -256,8 +261,10 @@ export const HandleBackup = ({
databaseType: backup?.databaseType ?? databaseType,
backupType: backup?.backupType ?? backupType,
metadata: backup?.metadata ?? {},
includePaths: backup?.includePaths ?? undefined,
excludePaths: backup?.excludePaths ?? (databaseType === "web-server" ? ["/applications/**", "/compose/**"] : undefined),
});
}, [form, form.reset, backupId, backup]);
}, [form, form.reset, backupId, backup, databaseType]);

const onSubmit = async (data: z.infer<typeof Schema>) => {
const getDatabaseId =
Expand Down Expand Up @@ -300,6 +307,8 @@ export const HandleBackup = ({
backupId: backupId ?? "",
backupType,
metadata: data.metadata,
includePaths: data.includePaths,
excludePaths: data.excludePaths,
})
.then(async () => {
toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
Expand Down Expand Up @@ -624,6 +633,142 @@ export const HandleBackup = ({
);
}}
/>
{databaseType === "web-server" && (
<>
<FormField
control={form.control}
name="excludePaths"
render={({ field }) => {
const [inputValue, setInputValue] = useState("");
const paths = field.value || [];

const addPath = () => {
if (inputValue.trim() && !paths.includes(inputValue.trim())) {
field.onChange([...paths, inputValue.trim()]);
setInputValue("");
}
};

const removePath = (path: string) => {
field.onChange(paths.filter((p) => p !== path));
};

return (
<FormItem>
<FormLabel>Exclude Paths</FormLabel>
<FormDescription>
Specify paths to exclude from the backup using glob patterns (e.g., /applications/**, /compose/**)
</FormDescription>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="/applications/**"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addPath();
}
}}
/>
</FormControl>
<Button type="button" onClick={addPath} variant="secondary">
<PlusIcon className="h-4 w-4" />
</Button>
</div>
{paths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{paths.map((path) => (
<div
key={path}
className="flex items-center gap-1 bg-muted px-2 py-1 rounded-md text-sm"
>
<span>{path}</span>
<button
type="button"
onClick={() => removePath(path)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="includePaths"
render={({ field }) => {
const [inputValue, setInputValue] = useState("");
const paths = field.value || [];

const addPath = () => {
if (inputValue.trim() && !paths.includes(inputValue.trim())) {
field.onChange([...paths, inputValue.trim()]);
setInputValue("");
}
};

const removePath = (path: string) => {
field.onChange(paths.filter((p) => p !== path));
};

return (
<FormItem>
<FormLabel>Include Paths (Optional)</FormLabel>
<FormDescription>
Specify specific paths to include. If specified, only these paths will be backed up (e.g., /traefik/**, /ssh/**)
</FormDescription>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="/traefik/**"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addPath();
}
}}
/>
</FormControl>
<Button type="button" onClick={addPath} variant="secondary">
<PlusIcon className="h-4 w-4" />
</Button>
</div>
{paths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{paths.map((path) => (
<div
key={path}
className="flex items-center gap-1 bg-muted px-2 py-1 rounded-md text-sm"
>
<span>{path}</span>
<button
type="button"
onClick={() => removePath(path)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
<FormField
control={form.control}
name="enabled"
Expand Down
9 changes: 9 additions & 0 deletions packages/server/src/db/schema/backups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export const backups = pgTable("backup", {
}
| undefined
>(),
// Path filtering for web-server backups
includePaths: jsonb("includePaths").$type<string[] | undefined>(),
excludePaths: jsonb("excludePaths").$type<string[] | undefined>(),
});

export const backupsRelations = relations(backups, ({ one, many }) => ({
Expand Down Expand Up @@ -144,6 +147,8 @@ const createSchema = createInsertSchema(backups, {
mongoId: z.string().optional(),
userId: z.string().optional(),
metadata: z.any().optional(),
includePaths: z.array(z.string()).optional(),
excludePaths: z.array(z.string()).optional(),
});

export const apiCreateBackup = createSchema.pick({
Expand All @@ -163,6 +168,8 @@ export const apiCreateBackup = createSchema.pick({
composeId: true,
serviceName: true,
metadata: true,
includePaths: true,
excludePaths: true,
});

export const apiFindOneBackup = createSchema
Expand All @@ -189,6 +196,8 @@ export const apiUpdateBackup = createSchema
serviceName: true,
metadata: true,
databaseType: true,
includePaths: true,
excludePaths: true,
})
.required();

Expand Down
26 changes: 23 additions & 3 deletions packages/server/src/utils/backups/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,29 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
writeStream.write(`Cleaning up temp file: ${cleanupCommand}\n`);
await execAsync(cleanupCommand);

await execAsync(
`rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`,
);
// Build rsync command with include/exclude patterns
let rsyncCommand = "rsync -a --ignore-errors";

// Add include patterns if specified
if (backup.includePaths && backup.includePaths.length > 0) {
for (const pattern of backup.includePaths) {
rsyncCommand += ` --include="${pattern}"`;
}
// When using --include, we need to exclude everything else
rsyncCommand += ' --exclude="*"';
}

// Add exclude patterns if specified
if (backup.excludePaths && backup.excludePaths.length > 0) {
for (const pattern of backup.excludePaths) {
rsyncCommand += ` --exclude="${pattern}"`;
}
}

rsyncCommand += ` ${BASE_PATH}/ ${tempDir}/filesystem/`;

writeStream.write(`Running rsync command: ${rsyncCommand}\n`);
await execAsync(rsyncCommand);

writeStream.write("Copied filesystem to temp directory\n");

Expand Down