diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 8bd9a8f..e6b8892 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -756,6 +756,15 @@ export type ListRepositoriesResponses = { password?: string; path?: string; username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -763,7 +772,7 @@ export type ListRepositoriesResponses = { lastError: string | null; name: string; status: 'error' | 'healthy' | 'unknown' | null; - type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; }>; }; @@ -823,6 +832,15 @@ export type CreateRepositoryData = { password?: string; path?: string; username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; }; name: string; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; @@ -952,6 +970,15 @@ export type GetRepositoryResponses = { password?: string; path?: string; username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -959,7 +986,7 @@ export type GetRepositoryResponses = { lastError: string | null; name: string; status: 'error' | 'healthy' | 'unknown' | null; - type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; }; }; @@ -1084,6 +1111,7 @@ export type RestoreSnapshotData = { snapshotId: string; delete?: boolean; exclude?: Array; + excludeXattr?: Array; include?: Array; }; path: { @@ -1208,6 +1236,15 @@ export type ListBackupSchedulesResponses = { password?: string; path?: string; username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1215,7 +1252,7 @@ export type ListBackupSchedulesResponses = { lastError: string | null; name: string; status: 'error' | 'healthy' | 'unknown' | null; - type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; }; repositoryId: string; @@ -1430,6 +1467,15 @@ export type GetBackupScheduleResponses = { password?: string; path?: string; username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1437,7 +1483,7 @@ export type GetBackupScheduleResponses = { lastError: string | null; name: string; status: 'error' | 'healthy' | 'unknown' | null; - type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; }; repositoryId: string; @@ -1633,6 +1679,15 @@ export type GetBackupScheduleForVolumeResponses = { password?: string; path?: string; username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1640,7 +1695,7 @@ export type GetBackupScheduleForVolumeResponses = { lastError: string | null; name: string; status: 'error' | 'healthy' | 'unknown' | null; - type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3'; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; }; repositoryId: string; diff --git a/app/client/components/status-dot.tsx b/app/client/components/status-dot.tsx index 99c6611..19a42c4 100644 --- a/app/client/components/status-dot.tsx +++ b/app/client/components/status-dot.tsx @@ -42,7 +42,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => { - {statusMapping.animated && ( + {statusMapping?.animated && ( { )} /> )} - + diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 6c40f3f..135a344 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -1,11 +1,12 @@ import { useCallback, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FileIcon } from "lucide-react"; +import { ChevronDown, FileIcon } from "lucide-react"; import { FileTree } 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 { Input } from "~/client/components/ui/input"; import { AlertDialog, AlertDialogAction, @@ -39,6 +40,8 @@ export const SnapshotFileBrowser = (props: Props) => { const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [showRestoreDialog, setShowRestoreDialog] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [excludeXattr, setExcludeXattr] = useState(""); const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; @@ -64,9 +67,11 @@ export const SnapshotFileBrowser = (props: Props) => { const addBasePath = useCallback( (displayPath: string): string => { - if (!volumeBasePath) return displayPath; - if (displayPath === "/") return volumeBasePath; - return `${volumeBasePath}${displayPath}`; + let vbp = volumeBasePath === "/" ? "" : volumeBasePath; + + if (!vbp) return displayPath; + if (displayPath === "/") return vbp; + return `${vbp}${displayPath}`; }, [volumeBasePath], ); @@ -117,17 +122,23 @@ export const SnapshotFileBrowser = (props: Props) => { const pathsArray = Array.from(selectedPaths); const includePaths = pathsArray.map((path) => addBasePath(path)); + const excludeXattrArray = excludeXattr + ?.split(",") + .map((s) => s.trim()) + .filter(Boolean); + restoreSnapshot({ path: { name: repositoryName }, body: { snapshotId: snapshot.short_id, include: includePaths, delete: deleteExtraFiles, + excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, }, }); setShowRestoreDialog(false); - }, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]); + }, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]); return (
@@ -220,15 +231,46 @@ export const SnapshotFileBrowser = (props: Props) => { Existing files will be overwritten by what's in the snapshot. This action cannot be undone. -
- setDeleteExtraFiles(checked === true)} - /> - +
+
+ + + {showAdvanced && ( +
+ + setExcludeXattr(e.target.value)} + /> +

+ Exclude specific extended attributes during restore (comma-separated) +

+
+ setDeleteExtraFiles(checked === true)} + /> + +
+
+ )} +
Cancel diff --git a/app/client/modules/repositories/components/restore-snapshot-dialog.tsx b/app/client/modules/repositories/components/restore-snapshot-dialog.tsx index 83792ba..f7e64d5 100644 --- a/app/client/modules/repositories/components/restore-snapshot-dialog.tsx +++ b/app/client/modules/repositories/components/restore-snapshot-dialog.tsx @@ -52,12 +52,18 @@ export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => { .map((s) => s.trim()) .filter(Boolean); + const excludeXattr = values.excludeXattr + ?.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, + excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : undefined, }, }); }; diff --git a/app/client/modules/repositories/components/restore-snapshot-form.tsx b/app/client/modules/repositories/components/restore-snapshot-form.tsx index a6eee0a..2552992 100644 --- a/app/client/modules/repositories/components/restore-snapshot-form.tsx +++ b/app/client/modules/repositories/components/restore-snapshot-form.tsx @@ -1,5 +1,7 @@ import { arktypeResolver } from "@hookform/resolvers/arktype"; import { type } from "arktype"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { Form, @@ -11,11 +13,13 @@ import { FormMessage, } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; +import { Button } from "~/client/components/ui/button"; const restoreSnapshotFormSchema = type({ path: "string?", include: "string?", exclude: "string?", + excludeXattr: "string?", }); export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn; @@ -27,12 +31,15 @@ type Props = { }; export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => { + const [showAdvanced, setShowAdvanced] = useState(false); + const form = useForm({ resolver: arktypeResolver(restoreSnapshotFormSchema), defaultValues: { path: "", include: "", exclude: "", + excludeXattr: "", }, }); @@ -90,6 +97,43 @@ export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => { )} /> + +
+ + + {showAdvanced && ( +
+ ( + + Exclude Extended Attributes (Optional) + + + + + Exclude specific extended attributes during restore (comma-separated) + + + + )} + /> +
+ )} +
diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index 88f5262..8d85808 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -237,6 +237,7 @@ export const restoreSnapshotBody = type({ snapshotId: "string", include: "string[]?", exclude: "string[]?", + excludeXattr: "string[]?", delete: "boolean?", }); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 779c5df..4c711d5 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -15,7 +15,7 @@ const listRepositories = async () => { }; const encryptConfig = async (config: RepositoryConfig): Promise => { - const encryptedConfig: Record = { ...config }; + const encryptedConfig: Record = { ...config }; if (config.customPassword) { encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword); @@ -193,7 +193,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string const restoreSnapshot = async ( name: string, snapshotId: string, - options?: { include?: string[]; exclude?: string[]; delete?: boolean }, + options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean }, ) => { const repository = await db.query.repositoriesTable.findFirst({ where: eq(repositoriesTable.name, name), diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 97146d8..658e094 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -351,6 +351,7 @@ const restore = async ( options?: { include?: string[]; exclude?: string[]; + excludeXattr?: string[]; path?: string; delete?: boolean; }, @@ -380,11 +381,15 @@ const restore = async ( } } + if (options?.excludeXattr && options.excludeXattr.length > 0) { + for (const xattr of options.excludeXattr) { + args.push("--exclude-xattr", xattr); + } + } + addRepoSpecificArgs(args, config, env); args.push("--json"); - console.log("Restic restore command:", ["restic", ...args].join(" ")); - const res = await $`restic ${args}`.env(env).nothrow(); await cleanupTemporaryKeys(config, env); @@ -408,6 +413,7 @@ const restore = async ( }; } + logger.debug(`Restic restore output last line: ${lastLine}`); const resSummary = JSON.parse(lastLine); const result = restoreOutputSchema(resSummary);