Compare commits

..

4 Commits

Author SHA1 Message Date
Nico
3bf3b22b96 feat: restore to custom location (#78)
* feat: restore to custom location

* refactor: define overwrite mode in shared schema
2025-11-29 16:53:44 +01:00
Nicolas Meienberger
58708cf35d refactor: repo healthcheck once per day 2025-11-29 12:25:46 +01:00
Nicolas Meienberger
1d4e7100ab fix: healtcheck, to not read full data 2025-11-29 12:24:07 +01:00
Nicolas Meienberger
0dfe000148 feat: rename volumes & repositories 2025-11-28 20:47:27 +01:00
13 changed files with 323 additions and 90 deletions

View File

@@ -1224,6 +1224,8 @@ export type RestoreSnapshotData = {
exclude?: Array<string>; exclude?: Array<string>;
excludeXattr?: Array<string>; excludeXattr?: Array<string>;
include?: Array<string>; include?: Array<string>;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
targetPath?: string;
}; };
path: { path: {
name: string; name: string;

View File

@@ -115,8 +115,6 @@ export const CreateRepositoryForm = ({
onChange={(e) => field.onChange(slugify(e.target.value))} onChange={(e) => field.onChange(slugify(e.target.value))}
max={32} max={32}
min={2} min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/> />
</FormControl> </FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription> <FormDescription>Unique identifier for the repository.</FormDescription>

View File

@@ -104,8 +104,6 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
onChange={(e) => field.onChange(slugify(e.target.value))} onChange={(e) => field.onChange(slugify(e.target.value))}
max={32} max={32}
min={1} min={1}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/> />
</FormControl> </FormControl>
<FormDescription>Unique identifier for the volume.</FormDescription> <FormDescription>Unique identifier for the volume.</FormDescription>

View File

@@ -1,12 +1,13 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, FileIcon } from "lucide-react"; import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
import { FileTree } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -22,6 +23,9 @@ import type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
type RestoreLocation = "original" | "custom";
interface Props { interface Props {
snapshot: Snapshot; snapshot: Snapshot;
@@ -42,6 +46,9 @@ export const SnapshotFileBrowser = (props: Props) => {
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState(""); const [excludeXattr, setExcludeXattr] = useState("");
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
@@ -127,6 +134,9 @@ export const SnapshotFileBrowser = (props: Props) => {
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const isCustomLocation = restoreLocation === "custom";
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
restoreSnapshot({ restoreSnapshot({
path: { name: repositoryName }, path: { name: repositoryName },
body: { body: {
@@ -134,11 +144,24 @@ export const SnapshotFileBrowser = (props: Props) => {
include: includePaths, include: includePaths,
delete: deleteExtraFiles, delete: deleteExtraFiles,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath,
overwrite: overwriteMode,
}, },
}); });
setShowRestoreDialog(false); setShowRestoreDialog(false);
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]); }, [
selectedPaths,
addBasePath,
repositoryName,
snapshot.short_id,
restoreSnapshot,
deleteExtraFiles,
excludeXattr,
restoreLocation,
customTargetPath,
overwriteMode,
]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -221,17 +244,77 @@ export const SnapshotFileBrowser = (props: Props) => {
</Card> </Card>
<AlertDialog open={showRestoreDialog} onOpenChange={setShowRestoreDialog}> <AlertDialog open={showRestoreDialog} onOpenChange={setShowRestoreDialog}>
<AlertDialogContent> <AlertDialogContent className="max-w-lg">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Restore</AlertDialogTitle> <AlertDialogTitle>Confirm Restore</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{selectedPaths.size > 0 {selectedPaths.size > 0
? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.` ? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.`
: "This will restore everything from the snapshot."}{" "} : "This will restore everything from the snapshot."}
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3">
<Label className="text-sm font-medium">Restore Location</Label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={restoreLocation === "original" ? "secondary" : "outline"}
size="sm"
className="flex justify-start gap-2"
onClick={() => setRestoreLocation("original")}
>
<RotateCcw size={16} className="mr-1" />
Original location
</Button>
<Button
type="button"
variant={restoreLocation === "custom" ? "secondary" : "outline"}
size="sm"
className="justify-start gap-2"
onClick={() => setRestoreLocation("custom")}
>
<FolderOpen size={16} className="mr-1" />
Custom location
</Button>
</div>
{restoreLocation === "custom" && (
<div className="space-y-2">
<Input
placeholder="/path/to/restore"
value={customTargetPath}
onChange={(e) => setCustomTargetPath(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Files will be restored directly to this path</p>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Overwrite Mode</Label>
<Select value={overwriteMode} onValueChange={(value) => setOverwriteMode(value as OverwriteMode)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select overwrite behavior" />
</SelectTrigger>
<SelectContent>
<SelectItem value={OVERWRITE_MODES.always}>Always overwrite</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifChanged}>Only if content changed</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifNewer}>Only if snapshot is newer</SelectItem>
<SelectItem value={OVERWRITE_MODES.never}>Never overwrite</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{overwriteMode === OVERWRITE_MODES.always &&
"Existing files will always be replaced with the snapshot version."}
{overwriteMode === OVERWRITE_MODES.ifChanged &&
"Files are only replaced if their content differs from the snapshot."}
{overwriteMode === OVERWRITE_MODES.ifNewer &&
"Files are only replaced if the snapshot version has a newer modification time."}
{overwriteMode === OVERWRITE_MODES.never &&
"Existing files will never be replaced, only missing files are restored."}
</p>
</div>
<div> <div>
<Button <Button
type="button" type="button"
@@ -240,14 +323,15 @@ export const SnapshotFileBrowser = (props: Props) => {
onClick={() => setShowAdvanced(!showAdvanced)} onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal" className="h-auto p-0 text-sm font-normal"
> >
Advanced Advanced Options
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} /> <ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</Button> </Button>
{showAdvanced && ( {showAdvanced && (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-3">
<div className="space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm"> <Label htmlFor="exclude-xattr" className="text-sm">
Exclude Extended Attributes (Optional) Exclude Extended Attributes
</Label> </Label>
<Input <Input
id="exclude-xattr" id="exclude-xattr"
@@ -258,14 +342,15 @@ export const SnapshotFileBrowser = (props: Props) => {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated) Exclude specific extended attributes during restore (comma-separated)
</p> </p>
<div className="flex items-center space-x-2 mt-2"> </div>
<div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="delete-extra" id="delete-extra"
checked={deleteExtraFiles} checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)} onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/> />
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer"> <Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot? Delete files not present in the snapshot
</Label> </Label>
</div> </div>
</div> </div>
@@ -274,7 +359,12 @@ export const SnapshotFileBrowser = (props: Props) => {
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmRestore}>Confirm</AlertDialogAction> <AlertDialogAction
onClick={handleConfirmRestore}
disabled={restoreLocation === "custom" && !customTargetPath.trim()}
>
Confirm Restore
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -114,8 +114,6 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
onChange={(e) => field.onChange(slugify(e.target.value))} onChange={(e) => field.onChange(slugify(e.target.value))}
max={32} max={32}
min={2} min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/> />
</FormControl> </FormControl>
<FormDescription>Unique identifier for this notification destination.</FormDescription> <FormDescription>Unique identifier for this notification destination.</FormDescription>

View File

@@ -1,36 +1,121 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { useNavigate } from "react-router";
import { Card } from "~/client/components/ui/card"; import { Card } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import type { Repository } from "~/client/lib/types"; import type { Repository } from "~/client/lib/types";
import { slugify } from "~/client/lib/utils";
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { UpdateRepositoryResponse } from "~/client/api-client/types.gen";
import type { CompressionMode } from "~/schemas/restic";
type Props = { type Props = {
repository: Repository; repository: Repository;
}; };
export const RepositoryInfoTabContent = ({ repository }: Props) => { export const RepositoryInfoTabContent = ({ repository }: Props) => {
const navigate = useNavigate();
const [name, setName] = useState(repository.name);
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
(repository.compressionMode as CompressionMode) || "off",
);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const updateMutation = useMutation({
...updateRepositoryMutation(),
onSuccess: (data: UpdateRepositoryResponse) => {
toast.success("Repository updated successfully");
setShowConfirmDialog(false);
if (data.name !== repository.name) {
navigate(`/repositories/${data.name}`);
}
},
onError: (error) => {
toast.error("Failed to update repository", { description: error.message, richColors: true });
setShowConfirmDialog(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setShowConfirmDialog(true);
};
const confirmUpdate = () => {
updateMutation.mutate({
path: { name: repository.name },
body: { name, compressionMode },
});
};
const hasChanges =
name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off");
return ( return (
<>
<Card className="p-6"> <Card className="p-6">
<div className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Settings</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(slugify(e.target.value))}
placeholder="Repository name"
maxLength={32}
minLength={2}
/>
<p className="text-sm text-muted-foreground">Unique identifier for the repository.</p>
</div>
<div className="space-y-2">
<Label htmlFor="compressionMode">Compression Mode</Label>
<Select value={compressionMode} onValueChange={(val) => setCompressionMode(val as CompressionMode)}>
<SelectTrigger id="compressionMode">
<SelectValue placeholder="Select compression mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="max">Max</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Compression level for new data.</p>
</div>
</div>
</div>
<div> <div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3> <h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
</div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Backend</div> <div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p> <p className="mt-1 text-sm">{repository.type}</p>
</div> </div>
<div>
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Status</div> <div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p> <p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Created At</div> <div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p> <p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div> <div className="text-sm font-medium text-muted-foreground">Last Checked</div>
@@ -40,24 +125,45 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
</div> </div>
</div> </div>
</div> </div>
{repository.lastError && ( {repository.lastError && (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-red-500">Last Error</h3> <h3 className="text-lg font-semibold text-red-500">Last Error</h3>
</div> </div>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4"> <div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p> <p className="text-sm text-red-500">{repository.lastError}</p>
</div> </div>
</div> </div>
)} )}
<div> <div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3> <h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4"> <div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre> <pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div> </div>
</div> </div>
<div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={!hasChanges || updateMutation.isPending} loading={updateMutation.isPending}>
Save Changes
</Button>
</div> </div>
</form>
</Card> </Card>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update Repository</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to update the repository settings?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
); );
}; };

View File

@@ -1,5 +1,6 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form"; import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
import { import {
@@ -17,6 +18,7 @@ import type { StatFs, Volume } from "~/client/lib/types";
import { HealthchecksCard } from "../components/healthchecks-card"; import { HealthchecksCard } from "../components/healthchecks-card";
import { StorageChart } from "../components/storage-chart"; import { StorageChart } from "../components/storage-chart";
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { UpdateVolumeResponse } from "~/client/api-client/types.gen";
type Props = { type Props = {
volume: Volume; volume: Volume;
@@ -24,12 +26,18 @@ type Props = {
}; };
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => { export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const navigate = useNavigate();
const updateMutation = useMutation({ const updateMutation = useMutation({
...updateVolumeMutation(), ...updateVolumeMutation(),
onSuccess: (_) => { onSuccess: (data: UpdateVolumeResponse) => {
toast.success("Volume updated successfully"); toast.success("Volume updated successfully");
setOpen(false); setOpen(false);
setPendingValues(null); setPendingValues(null);
if (data.name !== volume.name) {
navigate(`/volumes/${data.name}`);
}
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to update volume", { description: error.message }); toast.error("Failed to update volume", { description: error.message });
@@ -50,7 +58,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
if (pendingValues) { if (pendingValues) {
updateMutation.mutate({ updateMutation.mutate({
path: { name: volume.name }, path: { name: volume.name },
body: { config: pendingValues }, body: { name: pendingValues.name, config: pendingValues },
}); });
} }
}; };

View File

@@ -105,3 +105,12 @@ export const REPOSITORY_STATUS = {
} as const; } as const;
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS; export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
export const OVERWRITE_MODES = {
always: "always",
ifChanged: "if-changed",
ifNewer: "if-newer",
never: "never",
} as const;
export type OverwriteMode = (typeof OVERWRITE_MODES)[keyof typeof OVERWRITE_MODES];

View File

@@ -34,7 +34,7 @@ export const startup = async () => {
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *"); Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *"); Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
Scheduler.build(RepositoryHealthCheckJob).schedule("0 * * * *"); Scheduler.build(RepositoryHealthCheckJob).schedule("0 12 * * *");
Scheduler.build(BackupExecutionJob).schedule("* * * * *"); Scheduler.build(BackupExecutionJob).schedule("* * * * *");
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *"); Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
}; };

View File

@@ -1,6 +1,12 @@
import { type } from "arktype"; import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi"; import { describeRoute, resolver } from "hono-openapi";
import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryConfigSchema } from "~/schemas/restic"; import {
COMPRESSION_MODES,
OVERWRITE_MODES,
REPOSITORY_BACKENDS,
REPOSITORY_STATUS,
repositoryConfigSchema,
} from "~/schemas/restic";
export const repositorySchema = type({ export const repositorySchema = type({
id: "string", id: "string",
@@ -269,12 +275,16 @@ export const listSnapshotFilesDto = describeRoute({
/** /**
* Restore a snapshot * Restore a snapshot
*/ */
export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES);
export const restoreSnapshotBody = type({ export const restoreSnapshotBody = type({
snapshotId: "string", snapshotId: "string",
include: "string[]?", include: "string[]?",
exclude: "string[]?", exclude: "string[]?",
excludeXattr: "string[]?", excludeXattr: "string[]?",
delete: "boolean?", delete: "boolean?",
targetPath: "string?",
overwrite: overwriteModeSchema.optional(),
}); });
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer; export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;

View File

@@ -8,7 +8,7 @@ import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id"; import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto"; import { cryptoUtils } from "../../utils/crypto";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic";
const listRepositories = async () => { const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({}); const repositories = await db.query.repositoriesTable.findMany({});
@@ -201,7 +201,14 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
const restoreSnapshot = async ( const restoreSnapshot = async (
name: string, name: string,
snapshotId: string, snapshotId: string,
options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean }, options?: {
include?: string[];
exclude?: string[];
excludeXattr?: string[];
delete?: boolean;
targetPath?: string;
overwrite?: OverwriteMode;
},
) => { ) => {
const repository = await db.query.repositoriesTable.findFirst({ const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name), where: eq(repositoriesTable.name, name),
@@ -211,7 +218,9 @@ const restoreSnapshot = async (
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const result = await restic.restore(repository.config, snapshotId, "/", options); const target = options?.targetPath || "/";
const result = await restic.restore(repository.config, snapshotId, target, options);
return { return {
success: true, success: true,
@@ -249,7 +258,7 @@ const checkHealth = async (repositoryId: string) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const { hasErrors, error } = await restic.check(repository.config, { readData: true }); const { hasErrors, error } = await restic.check(repository.config);
await db await db
.update(repositoriesTable) .update(repositoriesTable)

View File

@@ -9,7 +9,7 @@ import { logger } from "./logger";
import { cryptoUtils } from "./crypto"; import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto"; import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn"; import { safeSpawn } from "./spawn";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; import type { CompressionMode, RepositoryConfig, OverwriteMode } from "~/schemas/restic";
import { ResticError } from "./errors"; import { ResticError } from "./errors";
const backupOutputSchema = type({ const backupOutputSchema = type({
@@ -353,7 +353,7 @@ const backup = async (
const restoreOutputSchema = type({ const restoreOutputSchema = type({
message_type: "'summary'", message_type: "'summary'",
total_files: "number", total_files: "number?",
files_restored: "number", files_restored: "number",
files_skipped: "number", files_skipped: "number",
total_bytes: "number?", total_bytes: "number?",
@@ -369,8 +369,8 @@ const restore = async (
include?: string[]; include?: string[];
exclude?: string[]; exclude?: string[];
excludeXattr?: string[]; excludeXattr?: string[];
path?: string;
delete?: boolean; delete?: boolean;
overwrite?: OverwriteMode;
}, },
) => { ) => {
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
@@ -378,8 +378,8 @@ const restore = async (
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target]; const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
if (options?.path) { if (options?.overwrite) {
args[args.length - 4] = `${snapshotId}:${options.path}`; args.push("--overwrite", options.overwrite);
} }
if (options?.delete) { if (options?.delete) {
@@ -407,6 +407,7 @@ const restore = async (
addRepoSpecificArgs(args, config, env); addRepoSpecificArgs(args, config, env);
args.push("--json"); args.push("--json");
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);

View File

@@ -3,6 +3,10 @@
* This removes passwords and credentials from logs and error messages * This removes passwords and credentials from logs and error messages
*/ */
export const sanitizeSensitiveData = (text: string): string => { export const sanitizeSensitiveData = (text: string): string => {
if (process.env.NODE_ENV === "development") {
return text;
}
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***"); let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@"); sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");