Compare commits

...

16 Commits

Author SHA1 Message Date
Nicolas Meienberger
a91dede086 docs: bump version in readme 2025-12-01 19:48:55 +01:00
Nico
9b46737852 refactor(repositories): add a locking mechanism for restic operations (#94)
* refactor(repositories): add a locking mechanism for restic operations

* fix: add missing lock in list repositories
2025-12-01 19:47:21 +01:00
Nicolas Meienberger
999850dab8 Merge branch 'tvarohohlavy-telegram-notification' 2025-11-30 17:06:07 +01:00
Nicolas Meienberger
dbd9ae2241 chore: generate types 2025-11-30 17:05:40 +01:00
Nico
0287bca4bb restore as a page (#87)
* feat: add custom restore target directory

Adds the ability to restore snapshots to a custom directory instead of
only the original path. Closes #12.

Changes:
- Add target parameter to restore API endpoint
- Add directory picker UI in file browser restore dialog
- Add target input field in snapshot restore form
- Create reusable PathSelector component

Note: Run `bun run gen:api-client` after merging to regenerate types.

* refactor: path selector design

* refactor: unify restore snapshot dialogs

* refactor: restore snapshot as a page

* chore: fix liniting issues

* chore(create-notification): remove un-used prop

---------

Co-authored-by: Deepseek1 <Deepseek1@users.noreply.github.com>
2025-11-30 16:47:14 +01:00
Nico
9a9991eb9b restore as a page (#87)
* feat: add custom restore target directory

Adds the ability to restore snapshots to a custom directory instead of
only the original path. Closes #12.

Changes:
- Add target parameter to restore API endpoint
- Add directory picker UI in file browser restore dialog
- Add target input field in snapshot restore form
- Create reusable PathSelector component

Note: Run `bun run gen:api-client` after merging to regenerate types.

* refactor: path selector design

* refactor: unify restore snapshot dialogs

* refactor: restore snapshot as a page

* chore: fix liniting issues

* chore(create-notification): remove un-used prop

---------

Co-authored-by: Deepseek1 <Deepseek1@users.noreply.github.com>
2025-11-30 16:43:34 +01:00
Jakub Trávník
03b898f84c Update app/client/modules/notifications/components/create-notification-form.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-30 15:38:45 +01:00
Jakub Trávník
6fbb11fefe telegram notification 2025-11-30 15:15:26 +01:00
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
Nicolas Meienberger
7d9d3d5d3d fix: wrong compression modes used 2025-11-28 20:28:47 +01:00
Nicolas Meienberger
8e90c4ace1 refactor: native repository healthcheck 2025-11-28 08:20:06 +01:00
Nicolas Meienberger
803eb1cd76 chore: update favicon 2025-11-28 08:18:34 +01:00
Nico
673827f9f3 refactor: all timestamps to ms (#77)
* refactor: change all timestamps to be in miliseconds

* chore: format files

* chore: fix syntax error
2025-11-26 23:20:22 +01:00
60 changed files with 5213 additions and 3448 deletions

View File

@@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -78,7 +78,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -146,7 +146,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -205,7 +205,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ze
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
ports:
@@ -236,7 +236,7 @@ In order to enable this feature, you need to run Zerobyte with several items sha
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:

View File

@@ -709,7 +709,7 @@ export type ListRepositoriesResponses = {
* List of repositories
*/
200: Array<{
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -849,7 +849,7 @@ export type CreateRepositoryData = {
isExistingRepository?: boolean;
};
name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
compressionMode?: 'auto' | 'max' | 'off';
};
path?: never;
query?: never;
@@ -924,7 +924,7 @@ export type GetRepositoryResponses = {
* Repository details
*/
200: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -1002,7 +1002,7 @@ export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryRe
export type UpdateRepositoryData = {
body?: {
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
compressionMode?: 'auto' | 'max' | 'off';
name?: string;
};
path: {
@@ -1028,7 +1028,7 @@ export type UpdateRepositoryResponses = {
* Repository updated successfully
*/
200: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -1224,6 +1224,8 @@ export type RestoreSnapshotData = {
exclude?: Array<string>;
excludeXattr?: Array<string>;
include?: Array<string>;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
targetPath?: string;
};
path: {
name: string;
@@ -1295,7 +1297,7 @@ export type ListBackupSchedulesResponses = {
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -1528,7 +1530,7 @@ export type GetBackupScheduleResponses = {
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -1742,7 +1744,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -1963,6 +1965,10 @@ export type GetScheduleNotificationsResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2005,7 +2011,7 @@ export type GetScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@@ -2047,6 +2053,10 @@ export type UpdateScheduleNotificationsResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2089,7 +2099,7 @@ export type UpdateScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@@ -2120,6 +2130,10 @@ export type ListNotificationDestinationsResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2162,7 +2176,7 @@ export type ListNotificationDestinationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
}>;
};
@@ -2177,6 +2191,10 @@ export type CreateNotificationDestinationData = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2233,6 +2251,10 @@ export type CreateNotificationDestinationResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2275,7 +2297,7 @@ export type CreateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@@ -2336,6 +2358,10 @@ export type GetNotificationDestinationResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2378,7 +2404,7 @@ export type GetNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@@ -2393,6 +2419,10 @@ export type UpdateNotificationDestinationData = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2459,6 +2489,10 @@ export type UpdateNotificationDestinationResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2501,7 +2535,7 @@ export type UpdateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};

View File

@@ -115,8 +115,6 @@ export const CreateRepositoryForm = ({
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription>
@@ -176,10 +174,8 @@ export const CreateRepositoryForm = ({
</FormControl>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="fastest">Fastest</SelectItem>
<SelectItem value="better">Better</SelectItem>
<SelectItem value="max">Max</SelectItem>
<SelectItem value="auto">Auto (fast)</SelectItem>
<SelectItem value="max">Max (slower, better compression)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
@@ -237,8 +233,7 @@ export const CreateRepositoryForm = ({
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's master password or enter a custom password for the existing
repository.
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
</FormDescription>
</FormItem>

View File

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

View File

@@ -0,0 +1,39 @@
import { useState } from "react";
import { DirectoryBrowser } from "./directory-browser";
import { Button } from "./ui/button";
type Props = {
value: string;
onChange: (path: string) => void;
label?: string;
};
export const PathSelector = ({ value, onChange }: Props) => {
const [showBrowser, setShowBrowser] = useState(false);
if (showBrowser) {
return (
<div className="space-y-2">
<DirectoryBrowser
onSelectPath={(path) => {
onChange(path);
setShowBrowser(false);
}}
selectedPath={value}
/>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
Cancel
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">{value}</div>
<Button type="button" variant="outline" onClick={() => setShowBrowser(true)} size="sm">
Change
</Button>
</div>
);
};

View File

@@ -0,0 +1,325 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Checkbox } from "~/client/components/ui/checkbox";
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 { PathSelector } from "~/client/components/path-selector";
import { FileTree } from "~/client/components/file-tree";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import type { Snapshot } from "~/client/lib/types";
type RestoreLocation = "original" | "custom";
interface RestoreFormProps {
snapshot: Snapshot;
repositoryName: string;
snapshotId: string;
returnPath: string;
}
export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState("");
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path: volumeBasePath },
}),
enabled: !!repositoryName && !!snapshotId,
});
const stripBasePath = useCallback(
(path: string): string => {
if (!volumeBasePath) return path;
if (path === volumeBasePath) return "/";
if (path.startsWith(`${volumeBasePath}/`)) {
const stripped = path.slice(volumeBasePath.length);
return stripped;
}
return path;
},
[volumeBasePath],
);
const addBasePath = useCallback(
(displayPath: string): string => {
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (!vbp) return displayPath;
if (displayPath === "/") return vbp;
return `${vbp}${displayPath}`;
},
[volumeBasePath],
);
const fileBrowser = useFileBrowser({
initialData: filesData,
isLoading: filesLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path },
}),
);
},
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path },
}),
);
},
pathTransform: {
strip: stripBasePath,
add: addBasePath,
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Restore completed", {
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
});
navigate(returnPath);
},
onError: (error) => {
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
},
});
const handleRestore = useCallback(() => {
if (!repositoryName || !snapshotId) return;
const excludeXattrArray = excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const isCustomLocation = restoreLocation === "custom";
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path));
restoreSnapshot({
path: { name: repositoryName },
body: {
snapshotId,
include: includePaths.length > 0 ? includePaths : undefined,
delete: deleteExtraFiles,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath,
overwrite: overwriteMode,
},
});
}, [
repositoryName,
snapshotId,
excludeXattr,
restoreLocation,
customTargetPath,
selectedPaths,
addBasePath,
deleteExtraFiles,
overwriteMode,
restoreSnapshot,
]);
const canRestore = restoreLocation === "original" || customTargetPath.trim();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
<p className="text-sm text-muted-foreground">
{repositoryName} / {snapshotId}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(returnPath)}>
Cancel
</Button>
<Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>
{isRestoring
? "Restoring..."
: selectedPaths.size > 0
? `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`
: "Restore All"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Restore Location</CardTitle>
<CardDescription>Choose where to restore the files</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 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">
<PathSelector value={customTargetPath || "/"} onChange={setCustomTargetPath} />
<p className="text-xs text-muted-foreground">Files will be restored directly to this path</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Overwrite Mode</CardTitle>
<CardDescription>How to handle existing files</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<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>
</CardContent>
</Card>
<Card>
<CardHeader className="cursor-pointer" onClick={() => setShowAdvanced(!showAdvanced)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Advanced options</CardTitle>
<ChevronDown size={16} className={`transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</div>
</CardHeader>
{showAdvanced && (
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude extended attributes
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot
</Label>
</div>
</CardContent>
)}
</Card>
</div>
<Card className="lg:col-span-2 flex flex-col">
<CardHeader>
<CardTitle>Select Files to Restore</CardTitle>
<CardDescription>
{selectedPaths.size > 0
? `${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"} selected`
: "Select specific files or folders, or leave empty to restore everything"}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{fileBrowser.isLoading && (
<div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{fileBrowser.isEmpty && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p>
</div>
)}
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,47 +1,26 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, FileIcon } from "lucide-react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react";
import { Link } from "react-router";
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,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button, buttonVariants } from "~/client/components/ui/button";
import type { Snapshot } from "~/client/lib/types";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
interface Props {
snapshot: Snapshot;
repositoryName: string;
volume?: Volume;
backupId?: string;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
const queryClient = useQueryClient();
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(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] || "/";
@@ -67,7 +46,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const addBasePath = useCallback(
(displayPath: string): string => {
let vbp = volumeBasePath === "/" ? "" : volumeBasePath;
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (!vbp) return displayPath;
if (displayPath === "/") return vbp;
@@ -101,45 +80,6 @@ export const SnapshotFileBrowser = (props: Props) => {
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Restore completed", {
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
});
setSelectedPaths(new Set());
},
onError: (error) => {
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
},
});
const handleRestoreClick = useCallback(() => {
setShowRestoreDialog(true);
}, []);
const handleConfirmRestore = useCallback(() => {
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, excludeXattr]);
return (
<div className="space-y-4">
<Card className="h-[600px] flex flex-col">
@@ -150,30 +90,16 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div>
<div className="flex gap-2">
{selectedPaths.size > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={isReadOnly ? 0 : undefined}>
<Button
onClick={handleRestoreClick}
variant="primary"
size="sm"
disabled={isRestoring || isReadOnly}
>
{isRestoring
? "Restoring..."
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
</Button>
</span>
</TooltipTrigger>
{isReadOnly && (
<TooltipContent className="text-center">
<p>Volume is mounted as read-only.</p>
<p>Please remount with read-only disabled to restore files.</p>
</TooltipContent>
)}
</Tooltip>
)}
<Link
to={
backupId
? `/backups/${backupId}/${snapshot.short_id}/restore`
: `/repositories/${repositoryName}/${snapshot.short_id}/restore`
}
className={buttonVariants({ variant: "primary", size: "sm" })}
>
Restore
</Link>
{onDeleteSnapshot && (
<Button
variant="destructive"
@@ -211,73 +137,11 @@ export const SnapshotFileBrowser = (props: Props) => {
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/>
</div>
)}
</CardContent>
</Card>
<AlertDialog open={showRestoreDialog} onOpenChange={setShowRestoreDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Restore</AlertDialogTitle>
<AlertDialogDescription>
{selectedPaths.size > 0
? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} 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>
</AlertDialogHeader>
<div className="space-y-4">
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</Button>
{showAdvanced && (
<div className="mt-4 space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude Extended Attributes (Optional)
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot?
</Label>
</div>
</div>
)}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmRestore}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -70,8 +70,6 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
initialData: loaderData.schedule,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const {
@@ -240,7 +238,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
key={selectedSnapshot?.short_id}
snapshot={selectedSnapshot}
repositoryName={schedule.repository.name}
volume={schedule.volume}
backupId={schedule.id.toString()}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}
/>

View File

@@ -33,8 +33,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
const { data: schedules, isLoading } = useQuery({
...listBackupSchedulesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
if (isLoading) {

View File

@@ -0,0 +1,54 @@
import { redirect } from "react-router";
import { getBackupSchedule, getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Backups", href: "/backups" },
{ label: `Schedule #${match.params.id}`, href: `/backups/${match.params.id}` },
{ label: match.params.snapshotId },
{ label: "Restore" },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Restore files from a backup snapshot.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
if (!schedule.data) return redirect("/backups");
const repositoryName = schedule.data.repository.name;
const snapshot = await getSnapshotDetails({
path: { name: repositoryName, snapshotId: params.snapshotId },
});
if (!snapshot.data) return redirect(`/backups/${params.id}`);
return {
snapshot: snapshot.data,
repositoryName,
snapshotId: params.snapshotId,
backupId: params.id,
};
};
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
const { snapshot, repositoryName, snapshotId, backupId } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repositoryName={repositoryName}
snapshotId={snapshotId}
returnPath={`/backups/${backupId}`}
/>
);
}

View File

@@ -30,7 +30,6 @@ type Props = {
mode?: "create" | "update";
initialValues?: Partial<NotificationFormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
@@ -70,6 +69,11 @@ const defaultValuesForType = {
apiToken: "",
priority: 0 as const,
},
telegram: {
type: "telegram" as const,
botToken: "",
chatId: "",
},
custom: {
type: "custom" as const,
shoutrrrUrl: "",
@@ -114,8 +118,6 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for this notification destination.</FormDescription>
@@ -148,6 +150,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
</SelectContent>
</Select>
@@ -615,6 +618,41 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
</>
)}
{watchedType === "telegram" && (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl>
<FormDescription>
Telegram bot token. Get this from BotFather when you create your bot.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-1231234567890" />
</FormControl>
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "custom" && (
<FormField
control={form.control}

View File

@@ -62,12 +62,7 @@ export default function CreateNotification() {
</AlertDescription>
</Alert>
)}
<CreateNotificationForm
mode="create"
formId={formId}
onSubmit={handleSubmit}
loading={createNotification.isPending}
/>
<CreateNotificationForm mode="create" formId={formId} onSubmit={handleSubmit} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
Cancel

View File

@@ -171,20 +171,12 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
</AlertDescription>
</Alert>
)}
<>
<CreateNotificationForm
mode="update"
formId={formId}
onSubmit={handleSubmit}
initialValues={data.config}
loading={updateDestination.isPending}
/>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="submit" form={formId} loading={updateDestination.isPending}>
Save Changes
</Button>
</div>
</>
<CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="submit" form={formId} loading={updateDestination.isPending}>
Save Changes
</Button>
</div>
</CardContent>
</Card>

View File

@@ -49,8 +49,6 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
const { data } = useQuery({
...listNotificationDestinationsOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredNotifications =
@@ -102,6 +100,7 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
@@ -158,7 +157,10 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
<TableCell className="capitalize">{notification.type}</TableCell>
<TableCell className="text-center">
<StatusDot variant={notification.enabled ? "success" : "neutral"} label={notification.enabled ? "Enabled" : "Disabled"} />
<StatusDot
variant={notification.enabled ? "success" : "neutral"}
label={notification.enabled ? "Enabled" : "Disabled"}
/>
</TableCell>
</TableRow>
))

View File

@@ -1,100 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/client/components/ui/dialog";
import { ScrollArea } from "~/client/components/ui/scroll-area";
import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form";
type Props = {
name: string;
snapshotId: string;
};
export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
const [open, setOpen] = useState(false);
const formId = useId();
const restore = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Snapshot restored successfully", {
description: `${data.filesRestored} files restored, ${data.filesSkipped} files skipped`,
});
setOpen(false);
},
onError: (error) => {
toast.error("Failed to restore snapshot", {
description: parseError(error)?.message,
});
},
});
const handleSubmit = (values: RestoreSnapshotFormValues) => {
const include = values.include
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const exclude = values.exclude
?.split(",")
.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,
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<RotateCcw size={16} className="mr-2" />
Restore
</Button>
</DialogTrigger>
<DialogContent>
<ScrollArea className="max-h-[600px] p-4">
<DialogHeader>
<DialogTitle>Restore Snapshot</DialogTitle>
<DialogDescription>
Restore snapshot {snapshotId.substring(0, 8)} to a local filesystem path
</DialogDescription>
</DialogHeader>
<RestoreSnapshotForm className="mt-4" formId={formId} onSubmit={handleSubmit} />
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={restore.isPending}>
{restore.isPending ? "Restoring..." : "Restore"}
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,141 +0,0 @@
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,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
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;
type Props = {
formId: string;
onSubmit: (values: RestoreSnapshotFormValues) => void;
className?: string;
};
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const form = useForm<RestoreSnapshotFormValues>({
resolver: arktypeResolver(restoreSnapshotFormSchema),
defaultValues: {
path: "",
include: "",
exclude: "",
excludeXattr: "",
},
});
const handleSubmit = (values: RestoreSnapshotFormValues) => {
onSubmit(values);
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(handleSubmit)} className={className}>
<div className="space-y-4">
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="/specific/path" {...field} />
</FormControl>
<FormDescription>
Restore only a specific path from the snapshot (leave empty to restore all)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="include"
render={({ field }) => (
<FormItem>
<FormLabel>Include Patterns (Optional)</FormLabel>
<FormControl>
<Input placeholder="*.txt,/documents/**" {...field} />
</FormControl>
<FormDescription>Include only files matching these patterns (comma-separated)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exclude"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Patterns (Optional)</FormLabel>
<FormControl>
<Input placeholder="*.log,/temp/**" {...field} />
</FormControl>
<FormDescription>Exclude files matching these patterns (comma-separated)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced
<ChevronDown
size={16}
className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
/>
</Button>
{showAdvanced && (
<div className="mt-4">
<FormField
control={form.control}
name="excludeXattr"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Extended Attributes (Optional)</FormLabel>
<FormControl>
<Input placeholder="com.apple.metadata,user.custom" {...field} />
</FormControl>
<FormDescription>
Exclude specific extended attributes during restore (comma-separated)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
</form>
</Form>
);
};

View File

@@ -50,8 +50,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
const { data } = useQuery({
...listRepositoriesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredRepositories =

View File

@@ -64,8 +64,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
const { data } = useQuery({
...getRepositoryOptions({ path: { name: loaderData.name } }),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
useEffect(() => {

View File

@@ -0,0 +1,45 @@
import { redirect } from "react-router";
import { getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name, href: `/repositories/${match.params.name}` },
{ label: match.params.snapshotId, href: `/repositories/${match.params.name}/${match.params.snapshotId}` },
{ label: "Restore" },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Restore files from a backup snapshot.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({
path: { name: params.name, snapshotId: params.snapshotId },
});
if (snapshot.data) return { snapshot: snapshot.data, name: params.name, snapshotId: params.snapshotId };
return redirect("/repositories");
};
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
const { snapshot, name, snapshotId } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repositoryName={name}
snapshotId={snapshotId}
returnPath={`/repositories/${name}/${snapshotId}`}
/>
);
}

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import { redirect, useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details";
@@ -63,7 +62,6 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<h1 className="text-2xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
</div>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />

View File

@@ -1,63 +1,169 @@
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 { 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 { 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 = {
repository: Repository;
};
export const RepositoryInfoTabContent = ({ repository }: Props) => {
return (
<Card className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<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 className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</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 className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
{repository.lastError && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
</div>
const navigate = useNavigate();
const [name, setName] = useState(repository.name);
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
(repository.compressionMode as CompressionMode) || "off",
);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
<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>
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 (
<>
<Card className="p-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>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
</div>
</div>
</Card>
{repository.lastError && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
</div>
<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>
</div>
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</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>
</form>
</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

@@ -18,8 +18,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
const { data, isFetching, failureReason } = useQuery({
...listSnapshotsOptions({ path: { name: repository.name } }),
refetchInterval: 10000,
refetchOnWindowFocus: true,
initialData: [],
});

View File

@@ -71,8 +71,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const { capabilities } = useSystemInfo();

View File

@@ -61,8 +61,6 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
const { data } = useQuery({
...listVolumesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredVolumes =

View File

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

View File

@@ -0,0 +1,47 @@
-- Convert timestamps from seconds to milliseconds (multiply by 1000)
-- Only convert values that appear to be in seconds (less than year 2100 threshold)
UPDATE `volumes_table` SET `last_health_check` = `last_health_check` * 1000 WHERE `last_health_check` < 4102444800;
--> statement-breakpoint
UPDATE `volumes_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `volumes_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `users_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `users_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `sessions_table` SET `expires_at` = `expires_at` * 1000 WHERE `expires_at` < 4102444800;
--> statement-breakpoint
UPDATE `sessions_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `repositories_table` SET `last_checked` = `last_checked` * 1000 WHERE `last_checked` < 4102444800;
--> statement-breakpoint
UPDATE `repositories_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `repositories_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedules_table` SET `last_backup_at` = `last_backup_at` * 1000 WHERE `last_backup_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedules_table` SET `next_backup_at` = `next_backup_at` * 1000 WHERE `next_backup_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedules_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedules_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `notification_destinations_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `notification_destinations_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedule_notifications_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `app_metadata` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `app_metadata` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;

View File

@@ -0,0 +1 @@
UPDATE `repositories_table` SET `compression_mode` = 'auto' WHERE `compression_mode` IN ('fastest', 'better');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -651,4 +651,3 @@
"indexes": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,653 @@
{
"id": "e50ff0fb-4111-4d20-b550-9407ee397517",
"prevId": "e52fe10a-3f36-4b21-abef-c15990d28363",
"version": "6",
"dialect": "sqlite",
"tables": {
"app_metadata": {
"name": "app_metadata",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_notifications_table": {
"name": "backup_schedule_notifications_table",
"columns": {
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"destination_id": {
"name": "destination_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notify_on_start": {
"name": "notify_on_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_success": {
"name": "notify_on_success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_failure": {
"name": "notify_on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"columnsFrom": ["schedule_id"],
"tableTo": "backup_schedules_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"columnsFrom": ["destination_id"],
"tableTo": "notification_destinations_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
"columns": ["schedule_id", "destination_id"],
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedules_table": {
"name": "backup_schedules_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"volume_id": {
"name": "volume_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retention_policy": {
"name": "retention_policy",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_patterns": {
"name": "exclude_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"include_patterns": {
"name": "include_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"last_backup_at": {
"name": "last_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_status": {
"name": "last_backup_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_error": {
"name": "last_backup_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"next_backup_at": {
"name": "next_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"columnsFrom": ["volume_id"],
"tableTo": "volumes_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"columnsFrom": ["repository_id"],
"tableTo": "repositories_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_destinations_table": {
"name": "notification_destinations_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"notification_destinations_table_name_unique": {
"name": "notification_destinations_table_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repositories_table": {
"name": "repositories_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"repositories_table_short_id_unique": {
"name": "repositories_table_short_id_unique",
"columns": ["short_id"],
"isUnique": true
},
"repositories_table_name_unique": {
"name": "repositories_table_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"columnsFrom": ["user_id"],
"tableTo": "users_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"has_downloaded_restic_password": {
"name": "has_downloaded_restic_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": ["username"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_short_id_unique": {
"name": "volumes_table_short_id_unique",
"columns": ["short_id"],
"isUnique": true
},
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,653 @@
{
"id": "d0bfd316-b8f5-459b-ab17-0ce679479321",
"prevId": "e50ff0fb-4111-4d20-b550-9407ee397517",
"version": "6",
"dialect": "sqlite",
"tables": {
"app_metadata": {
"name": "app_metadata",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_notifications_table": {
"name": "backup_schedule_notifications_table",
"columns": {
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"destination_id": {
"name": "destination_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notify_on_start": {
"name": "notify_on_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_success": {
"name": "notify_on_success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_failure": {
"name": "notify_on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"columnsFrom": ["schedule_id"],
"tableTo": "backup_schedules_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"columnsFrom": ["destination_id"],
"tableTo": "notification_destinations_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
"columns": ["schedule_id", "destination_id"],
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedules_table": {
"name": "backup_schedules_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"volume_id": {
"name": "volume_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retention_policy": {
"name": "retention_policy",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_patterns": {
"name": "exclude_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"include_patterns": {
"name": "include_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"last_backup_at": {
"name": "last_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_status": {
"name": "last_backup_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_error": {
"name": "last_backup_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"next_backup_at": {
"name": "next_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"columnsFrom": ["volume_id"],
"tableTo": "volumes_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"columnsFrom": ["repository_id"],
"tableTo": "repositories_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_destinations_table": {
"name": "notification_destinations_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"notification_destinations_table_name_unique": {
"name": "notification_destinations_table_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repositories_table": {
"name": "repositories_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"repositories_table_short_id_unique": {
"name": "repositories_table_short_id_unique",
"columns": ["short_id"],
"isUnique": true
},
"repositories_table_name_unique": {
"name": "repositories_table_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"columnsFrom": ["user_id"],
"tableTo": "users_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"has_downloaded_restic_password": {
"name": "has_downloaded_restic_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": ["username"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_short_id_unique": {
"name": "volumes_table_short_id_unique",
"columns": ["short_id"],
"isUnique": true
},
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,118 +1,132 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1755765658194,
"tag": "0000_known_madelyne_pryor",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1755775437391,
"tag": "0001_far_frank_castle",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1756930554198,
"tag": "0002_cheerful_randall",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1758653407064,
"tag": "0003_mature_hellcat",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1758961535488,
"tag": "0004_wealthy_tomas",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1759416698274,
"tag": "0005_simple_alice",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1760734377440,
"tag": "0006_secret_micromacro",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1761224911352,
"tag": "0007_watery_sersi",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1761414054481,
"tag": "0008_silent_lady_bullseye",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1762095226041,
"tag": "0009_little_adam_warlock",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1762610065889,
"tag": "0010_perfect_proemial_gods",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1763644043601,
"tag": "0011_familiar_stone_men",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1764100562084,
"tag": "0012_add_short_ids",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1764182159797,
"tag": "0013_elite_sprite",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1764182405089,
"tag": "0014_wild_echo",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1764182465287,
"tag": "0015_jazzy_sersi",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1755765658194,
"tag": "0000_known_madelyne_pryor",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1755775437391,
"tag": "0001_far_frank_castle",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1756930554198,
"tag": "0002_cheerful_randall",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1758653407064,
"tag": "0003_mature_hellcat",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1758961535488,
"tag": "0004_wealthy_tomas",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1759416698274,
"tag": "0005_simple_alice",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1760734377440,
"tag": "0006_secret_micromacro",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1761224911352,
"tag": "0007_watery_sersi",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1761414054481,
"tag": "0008_silent_lady_bullseye",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1762095226041,
"tag": "0009_little_adam_warlock",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1762610065889,
"tag": "0010_perfect_proemial_gods",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1763644043601,
"tag": "0011_familiar_stone_men",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1764100562084,
"tag": "0012_add_short_ids",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1764182159797,
"tag": "0013_elite_sprite",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1764182405089,
"tag": "0014_wild_echo",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1764182465287,
"tag": "0015_jazzy_sersi",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1764194697035,
"tag": "0016_fix-timestamps-to-ms",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1764357897219,
"tag": "0017_fix-compression-modes",
"breakpoints": true
}
]
}

View File

@@ -12,10 +12,12 @@ export default [
route("backups", "./client/modules/backups/routes/backups.tsx"),
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
route("backups/:id/:snapshotId/restore", "./client/modules/backups/routes/restore-snapshot.tsx"),
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
route("repositories/:name/:snapshotId/restore", "./client/modules/repositories/routes/restore-snapshot.tsx"),
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),

View File

@@ -7,6 +7,7 @@ export const NOTIFICATION_TYPES = {
gotify: "gotify",
ntfy: "ntfy",
pushover: "pushover",
telegram: "telegram",
custom: "custom",
} as const;
@@ -64,6 +65,12 @@ export const pushoverNotificationConfigSchema = type({
priority: "-1 | 0 | 1",
});
export const telegramNotificationConfigSchema = type({
type: "'telegram'",
botToken: "string",
chatId: "string",
});
export const customNotificationConfigSchema = type({
type: "'custom'",
shoutrrrUrl: "string",
@@ -75,6 +82,7 @@ export const notificationConfigSchema = emailNotificationConfigSchema
.or(gotifyNotificationConfigSchema)
.or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema)
.or(telegramNotificationConfigSchema)
.or(customNotificationConfigSchema);
export type NotificationConfig = typeof notificationConfigSchema.infer;

View File

@@ -93,8 +93,6 @@ export type RepositoryConfig = typeof repositoryConfigSchema.infer;
export const COMPRESSION_MODES = {
off: "off",
auto: "auto",
fastest: "fastest",
better: "better",
max: "max",
} as const;
@@ -107,3 +105,12 @@ export const REPOSITORY_STATUS = {
} as const;
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

@@ -0,0 +1,179 @@
import { logger } from "../utils/logger";
export type LockType = "shared" | "exclusive";
interface LockHolder {
id: string;
operation: string;
acquiredAt: number;
}
interface RepositoryLockState {
sharedHolders: Map<string, LockHolder>;
exclusiveHolder: LockHolder | null;
waitQueue: Array<{
type: LockType;
operation: string;
resolve: (lockId: string) => void;
}>;
}
class RepositoryMutex {
private locks = new Map<string, RepositoryLockState>();
private lockIdCounter = 0;
private getOrCreateState(repositoryId: string): RepositoryLockState {
let state = this.locks.get(repositoryId);
if (!state) {
state = {
sharedHolders: new Map(),
exclusiveHolder: null,
waitQueue: [],
};
this.locks.set(repositoryId, state);
}
return state;
}
private generateLockId(): string {
return `lock_${++this.lockIdCounter}_${Date.now()}`;
}
private cleanupStateIfEmpty(repositoryId: string): void {
const state = this.locks.get(repositoryId);
if (state && state.sharedHolders.size === 0 && !state.exclusiveHolder && state.waitQueue.length === 0) {
this.locks.delete(repositoryId);
}
}
async acquireShared(repositoryId: string, operation: string): Promise<() => void> {
const state = this.getOrCreateState(repositoryId);
const hasExclusiveWaiter = state.waitQueue.some((w) => w.type === "exclusive");
if (!state.exclusiveHolder && !hasExclusiveWaiter) {
const lockId = this.generateLockId();
state.sharedHolders.set(lockId, {
id: lockId,
operation,
acquiredAt: Date.now(),
});
return () => this.releaseShared(repositoryId, lockId);
}
logger.debug(`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation}`);
const lockId = await new Promise<string>((resolve) => {
state.waitQueue.push({ type: "shared", operation, resolve });
});
return () => this.releaseShared(repositoryId, lockId);
}
async acquireExclusive(repositoryId: string, operation: string): Promise<() => void> {
const state = this.getOrCreateState(repositoryId);
if (!state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0) {
const lockId = this.generateLockId();
state.exclusiveHolder = {
id: lockId,
operation,
acquiredAt: Date.now(),
};
return () => this.releaseExclusive(repositoryId, lockId);
}
logger.debug(
`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation} (shared: ${state.sharedHolders.size}, exclusive: ${state.exclusiveHolder ? "yes" : "no"}, queue: ${state.waitQueue.length})`,
);
const lockId = await new Promise<string>((resolve) => {
state.waitQueue.push({ type: "exclusive", operation, resolve });
});
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation} (${lockId})`);
return () => this.releaseExclusive(repositoryId, lockId);
}
private releaseShared(repositoryId: string, lockId: string): void {
const state = this.locks.get(repositoryId);
if (!state) {
return;
}
const holder = state.sharedHolders.get(lockId);
if (!holder) {
return;
}
state.sharedHolders.delete(lockId);
const duration = Date.now() - holder.acquiredAt;
logger.debug(`[Mutex] Released shared lock for repo ${repositoryId}: ${holder.operation} (held for ${duration}ms)`);
this.processWaitQueue(repositoryId);
this.cleanupStateIfEmpty(repositoryId);
}
private releaseExclusive(repositoryId: string, lockId: string): void {
const state = this.locks.get(repositoryId);
if (!state) {
return;
}
if (!state.exclusiveHolder || state.exclusiveHolder.id !== lockId) {
return;
}
const duration = Date.now() - state.exclusiveHolder.acquiredAt;
logger.debug(
`[Mutex] Released exclusive lock for repo ${repositoryId}: ${state.exclusiveHolder.operation} (held for ${duration}ms)`,
);
state.exclusiveHolder = null;
this.processWaitQueue(repositoryId);
this.cleanupStateIfEmpty(repositoryId);
}
private processWaitQueue(repositoryId: string): void {
const state = this.locks.get(repositoryId);
if (!state || state.waitQueue.length === 0) {
return;
}
if (state.exclusiveHolder) {
return;
}
const firstWaiter = state.waitQueue[0];
if (firstWaiter.type === "exclusive") {
if (state.sharedHolders.size === 0) {
state.waitQueue.shift();
const lockId = this.generateLockId();
state.exclusiveHolder = {
id: lockId,
operation: firstWaiter.operation,
acquiredAt: Date.now(),
};
firstWaiter.resolve(lockId);
}
} else {
while (state.waitQueue.length > 0 && state.waitQueue[0].type === "shared") {
const waiter = state.waitQueue.shift();
if (!waiter) break;
const lockId = this.generateLockId();
state.sharedHolders.set(lockId, {
id: lockId,
operation: waiter.operation,
acquiredAt: Date.now(),
});
waiter.resolve(lockId);
}
}
}
isLocked(repositoryId: string): boolean {
const state = this.locks.get(repositoryId);
if (!state) return false;
return state.exclusiveHolder !== null || state.sharedHolders.size > 0;
}
}
export const repoMutex = new RepositoryMutex();

View File

@@ -14,9 +14,9 @@ export const volumesTable = sqliteTable("volumes_table", {
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"),
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
});
@@ -30,8 +30,8 @@ export const usersTable = sqliteTable("users_table", {
username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(),
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", {
@@ -40,7 +40,7 @@ export const sessionsTable = sqliteTable("sessions_table", {
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "number" }).notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type Session = typeof sessionsTable.$inferSelect;
@@ -57,8 +57,8 @@ export const repositoriesTable = sqliteTable("repositories_table", {
status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type Repository = typeof repositoriesTable.$inferSelect;
@@ -90,8 +90,8 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning">(),
lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "number" }),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, {
@@ -115,8 +115,8 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
type: text().$type<NotificationType>().notNull(),
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
schedules: many(backupScheduleNotificationsTable),
@@ -138,7 +138,7 @@ export const backupScheduleNotificationsTable = sqliteTable(
notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false),
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
},
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
);
@@ -161,7 +161,7 @@ export type BackupScheduleNotification = typeof backupScheduleNotificationsTable
export const appMetadataTable = sqliteTable("app_metadata", {
key: text().primaryKey(),
value: text().notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type AppMetadata = typeof appMetadataTable.$inferSelect;

View File

@@ -4,6 +4,7 @@ import { logger } from "../utils/logger";
import { db } from "../db/db";
import { eq, or } from "drizzle-orm";
import { repositoriesTable } from "../db/schema";
import { repoMutex } from "../core/repository-mutex";
export class RepositoryHealthCheckJob extends Job {
async run() {
@@ -14,6 +15,11 @@ export class RepositoryHealthCheckJob extends Job {
});
for (const repository of repositories) {
if (repoMutex.isLocked(repository.id)) {
logger.debug(`Skipping health check for repository ${repository.name}: currently locked`);
continue;
}
try {
await repositoriesService.checkHealth(repository.id);
} catch (error) {

View File

@@ -3,7 +3,7 @@ import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
export class AuthService {
/**
@@ -30,7 +30,7 @@ export class AuthService {
logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
const expiresAt = Date.now() + SESSION_DURATION;
await db.insert(sessionsTable).values({
id: sessionId,
@@ -66,7 +66,7 @@ export class AuthService {
}
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
const expiresAt = Date.now() + SESSION_DURATION;
await db.insert(sessionsTable).values({
id: sessionId,

View File

@@ -11,6 +11,7 @@ import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backu
import { toMessage } from "../../utils/errors";
import { serverEvents } from "../../core/events";
import { notificationsService } from "../notifications/notifications.service";
import { repoMutex } from "../../core/repository-mutex";
const runningBackups = new Map<number, AbortController>();
@@ -209,7 +210,12 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await db
.update(backupSchedulesTable)
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null, nextBackupAt })
.set({
lastBackupStatus: "in_progress",
updatedAt: Date.now(),
lastBackupError: null,
nextBackupAt,
})
.where(eq(backupSchedulesTable.id, scheduleId));
const abortController = new AbortController();
@@ -236,21 +242,33 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.include = schedule.includePatterns;
}
const { exitCode } = await restic.backup(repository.config, volumePath, {
...backupOptions,
compressionMode: repository.compressionMode ?? "auto",
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
...progress,
});
},
});
const releaseBackupLock = await repoMutex.acquireShared(repository.id, `backup:${volume.name}`);
let exitCode: number;
try {
const result = await restic.backup(repository.config, volumePath, {
...backupOptions,
compressionMode: repository.compressionMode ?? "auto",
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
...progress,
});
},
});
exitCode = result.exitCode;
} finally {
releaseBackupLock();
}
if (schedule.retentionPolicy) {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
const releaseForgetLock = await repoMutex.acquireExclusive(repository.id, `forget:${volume.name}`);
try {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
} finally {
releaseForgetLock();
}
}
const nextBackupAt = calculateNextRun(schedule.cronExpression);
@@ -398,7 +416,13 @@ const runForget = async (scheduleId: number) => {
}
logger.info(`Manually running retention policy (forget) for schedule ${scheduleId}`);
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:manual:${scheduleId}`);
try {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
} finally {
releaseLock();
}
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`);
};

View File

@@ -2,13 +2,12 @@ import { eq, sql } from "drizzle-orm";
import { db } from "../../db/db";
import { appMetadataTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
import { REQUIRED_MIGRATIONS } from "~/server/core/constants";
const MIGRATION_KEY_PREFIX = "migration:";
export const recordMigrationCheckpoint = async (version: string): Promise<void> => {
const key = `${MIGRATION_KEY_PREFIX}${version}`;
const now = Math.floor(Date.now() / 1000);
const now = Date.now();
await db
.insert(appMetadataTable)

View File

@@ -129,7 +129,7 @@ const migrateRepositoryFolders = async (): Promise<MigrationResult> => {
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Math.floor(Date.now() / 1000),
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
@@ -155,7 +155,7 @@ const migrateRepositoryFolders = async (): Promise<MigrationResult> => {
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Math.floor(Date.now() / 1000),
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
} catch (error) {
@@ -175,7 +175,7 @@ const migrateRepositoryFolders = async (): Promise<MigrationResult> => {
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Math.floor(Date.now() / 1000),
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
} catch (error) {

View File

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

View File

@@ -5,6 +5,7 @@ import { buildDiscordShoutrrrUrl } from "./discord";
import { buildGotifyShoutrrrUrl } from "./gotify";
import { buildNtfyShoutrrrUrl } from "./ntfy";
import { buildPushoverShoutrrrUrl } from "./pushover";
import { buildTelegramShoutrrrUrl } from "./telegram";
import { buildCustomShoutrrrUrl } from "./custom";
export function buildShoutrrrUrl(config: NotificationConfig): string {
@@ -21,6 +22,8 @@ export function buildShoutrrrUrl(config: NotificationConfig): string {
return buildNtfyShoutrrrUrl(config);
case "pushover":
return buildPushoverShoutrrrUrl(config);
case "telegram":
return buildTelegramShoutrrrUrl(config);
case "custom":
return buildCustomShoutrrrUrl(config);
default: {

View File

@@ -0,0 +1,5 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildTelegramShoutrrrUrl(config: Extract<NotificationConfig, { type: "telegram" }>): string {
return `telegram://${config.botToken}@telegram?channels=${config.chatId}`;
}

View File

@@ -65,6 +65,11 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config,
apiToken: await cryptoUtils.encrypt(config.apiToken),
};
case "telegram":
return {
...config,
botToken: await cryptoUtils.encrypt(config.botToken),
};
case "custom":
return {
...config,
@@ -107,6 +112,11 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config,
apiToken: await cryptoUtils.decrypt(config.apiToken),
};
case "telegram":
return {
...config,
botToken: await cryptoUtils.decrypt(config.botToken),
};
case "custom":
return {
...config,
@@ -157,7 +167,7 @@ const updateDestination = async (
}
const updateData: Partial<NotificationDestination> = {
updatedAt: Math.floor(Date.now() / 1000),
updatedAt: Date.now(),
};
if (updates.name !== undefined) {

View File

@@ -1,6 +1,12 @@
import { type } from "arktype";
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({
id: "string",
@@ -269,12 +275,16 @@ export const listSnapshotFilesDto = describeRoute({
/**
* Restore a snapshot
*/
export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES);
export const restoreSnapshotBody = type({
snapshotId: "string",
include: "string[]?",
exclude: "string[]?",
excludeXattr: "string[]?",
delete: "boolean?",
targetPath: "string?",
overwrite: overwriteModeSchema.optional(),
});
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;

View File

@@ -8,7 +8,8 @@ import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import { repoMutex } from "../../core/repository-mutex";
import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic";
const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({});
@@ -160,15 +161,20 @@ const listSnapshots = async (name: string, backupId?: string) => {
throw new NotFoundError("Repository not found");
}
let snapshots = [];
const releaseLock = await repoMutex.acquireShared(repository.id, "snapshots");
try {
let snapshots = [];
if (backupId) {
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
} else {
snapshots = await restic.snapshots(repository.config);
if (backupId) {
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
} else {
snapshots = await restic.snapshots(repository.config);
}
return snapshots;
} finally {
releaseLock();
}
return snapshots;
};
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
@@ -180,28 +186,40 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
throw new NotFoundError("Repository not found");
}
const result = await restic.ls(repository.config, snapshotId, path);
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
try {
const result = await restic.ls(repository.config, snapshotId, path);
if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty");
if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty");
}
return {
snapshot: {
id: result.snapshot.id,
short_id: result.snapshot.short_id,
time: result.snapshot.time,
hostname: result.snapshot.hostname,
paths: result.snapshot.paths,
},
files: result.nodes,
};
} finally {
releaseLock();
}
return {
snapshot: {
id: result.snapshot.id,
short_id: result.snapshot.short_id,
time: result.snapshot.time,
hostname: result.snapshot.hostname,
paths: result.snapshot.paths,
},
files: result.nodes,
};
};
const restoreSnapshot = async (
name: 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({
where: eq(repositoriesTable.name, name),
@@ -211,14 +229,21 @@ const restoreSnapshot = async (
throw new NotFoundError("Repository not found");
}
const result = await restic.restore(repository.config, snapshotId, "/", options);
const target = options?.targetPath || "/";
return {
success: true,
message: "Snapshot restored successfully",
filesRestored: result.files_restored,
filesSkipped: result.files_skipped,
};
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
try {
const result = await restic.restore(repository.config, snapshotId, target, options);
return {
success: true,
message: "Snapshot restored successfully",
filesRestored: result.files_restored,
filesSkipped: result.files_skipped,
};
} finally {
releaseLock();
}
};
const getSnapshotDetails = async (name: string, snapshotId: string) => {
@@ -230,14 +255,19 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
throw new NotFoundError("Repository not found");
}
const snapshots = await restic.snapshots(repository.config);
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
try {
const snapshots = await restic.snapshots(repository.config);
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
if (!snapshot) {
throw new NotFoundError("Snapshot not found");
if (!snapshot) {
throw new NotFoundError("Snapshot not found");
}
return snapshot;
} finally {
releaseLock();
}
return snapshot;
};
const checkHealth = async (repositoryId: string) => {
@@ -249,21 +279,23 @@ const checkHealth = async (repositoryId: string) => {
throw new NotFoundError("Repository not found");
}
const { error, status } = await restic
.snapshots(repository.config)
.then(() => ({ error: null, status: "healthy" as const }))
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
try {
const { hasErrors, error } = await restic.check(repository.config);
await db
.update(repositoriesTable)
.set({
status,
lastChecked: Date.now(),
lastError: error,
})
.where(eq(repositoriesTable.id, repository.id));
await db
.update(repositoriesTable)
.set({
status: hasErrors ? "error" : "healthy",
lastChecked: Date.now(),
lastError: error,
})
.where(eq(repositoriesTable.id, repository.id));
return { status, lastError: error };
return { lastError: error };
} finally {
releaseLock();
}
};
const doctorRepository = async (name: string) => {
@@ -289,48 +321,51 @@ const doctorRepository = async (name: string) => {
error: unlockResult.error,
});
const checkResult = await restic.check(repository.config, { readData: false }).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
steps.push({
step: "check",
success: checkResult.success,
output: checkResult.output,
error: checkResult.error,
});
if (checkResult.hasErrors) {
const repairResult = await restic.repairIndex(repository.config).then(
(result) => ({ success: true, output: result.output, error: null }),
(error) => ({ success: false, output: null, error: toMessage(error) }),
);
steps.push({
step: "repair_index",
success: repairResult.success,
output: repairResult.output,
error: repairResult.error,
});
const recheckResult = await restic.check(repository.config, { readData: false }).then(
const releaseLock = await repoMutex.acquireExclusive(repository.id, "doctor");
try {
const checkResult = await restic.check(repository.config, { readData: false }).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
steps.push({
step: "recheck",
success: recheckResult.success,
output: recheckResult.output,
error: recheckResult.error,
step: "check",
success: checkResult.success,
output: checkResult.output,
error: checkResult.error,
});
if (checkResult.hasErrors) {
const repairResult = await restic.repairIndex(repository.config).then(
(result) => ({ success: true, output: result.output, error: null }),
(error) => ({ success: false, output: null, error: toMessage(error) }),
);
steps.push({
step: "repair_index",
success: repairResult.success,
output: repairResult.output,
error: repairResult.error,
});
const recheckResult = await restic.check(repository.config, { readData: false }).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
steps.push({
step: "recheck",
success: recheckResult.success,
output: recheckResult.output,
error: recheckResult.error,
});
}
} finally {
releaseLock();
}
const allSuccessful = steps.every((s) => s.success);
console.log("Doctor steps:", steps);
await db
.update(repositoriesTable)
.set({
@@ -355,7 +390,12 @@ const deleteSnapshot = async (name: string, snapshotId: string) => {
throw new NotFoundError("Repository not found");
}
await restic.deleteSnapshot(repository.config, snapshotId);
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
try {
await restic.deleteSnapshot(repository.config, snapshotId);
} finally {
releaseLock();
}
};
const updateRepository = async (name: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
@@ -396,7 +436,7 @@ const updateRepository = async (name: string, updates: { name?: string; compress
.set({
name: newName,
compressionMode: updates.compressionMode ?? existing.compressionMode,
updatedAt: Math.floor(Date.now() / 1000),
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, existing.id))
.returning();

View File

@@ -9,7 +9,7 @@ import { logger } from "./logger";
import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import type { CompressionMode, RepositoryConfig, OverwriteMode } from "~/schemas/restic";
import { ResticError } from "./errors";
const backupOutputSchema = type({
@@ -200,8 +200,8 @@ const init = async (config: RepositoryConfig) => {
const env = await buildEnv(config);
const args = ["init", "--repo", repoUrl, "--json"];
addRepoSpecificArgs(args, config, env);
const args = ["init", "--repo", repoUrl];
addCommonArgs(args, config, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -277,8 +277,7 @@ const backup = async (
}
}
addRepoSpecificArgs(args, config, env);
args.push("--json");
addCommonArgs(args, config, env);
const logData = throttle((data: string) => {
logger.info(data.trim());
@@ -353,7 +352,7 @@ const backup = async (
const restoreOutputSchema = type({
message_type: "'summary'",
total_files: "number",
total_files: "number?",
files_restored: "number",
files_skipped: "number",
total_bytes: "number?",
@@ -369,8 +368,8 @@ const restore = async (
include?: string[];
exclude?: string[];
excludeXattr?: string[];
path?: string;
delete?: boolean;
overwrite?: OverwriteMode;
},
) => {
const repoUrl = buildRepoUrl(config);
@@ -378,8 +377,8 @@ const restore = async (
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
if (options?.path) {
args[args.length - 4] = `${snapshotId}:${options.path}`;
if (options?.overwrite) {
args.push("--overwrite", options.overwrite);
}
if (options?.delete) {
@@ -404,9 +403,9 @@ const restore = async (
}
}
addRepoSpecificArgs(args, config, env);
args.push("--json");
addCommonArgs(args, config, env);
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -467,8 +466,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
}
}
addRepoSpecificArgs(args, config, env);
args.push("--json");
addCommonArgs(args, config, env);
const res = await $`restic ${args}`.env(env).nothrow().quiet();
await cleanupTemporaryKeys(config, env);
@@ -517,8 +515,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
}
args.push("--prune");
addRepoSpecificArgs(args, config, env);
args.push("--json");
addCommonArgs(args, config, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -536,7 +533,7 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
addRepoSpecificArgs(args, config, env);
addCommonArgs(args, config, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -580,13 +577,13 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--json", "--long"];
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
if (path) {
args.push(path);
}
addRepoSpecificArgs(args, config, env);
addCommonArgs(args, config, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
@@ -636,8 +633,8 @@ const unlock = async (config: RepositoryConfig) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
addRepoSpecificArgs(args, config, env);
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
addCommonArgs(args, config, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -661,7 +658,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
args.push("--read-data");
}
addRepoSpecificArgs(args, config, env);
addCommonArgs(args, config, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -695,7 +692,7 @@ const repairIndex = async (config: RepositoryConfig) => {
const env = await buildEnv(config);
const args = ["repair", "index", "--repo", repoUrl];
addRepoSpecificArgs(args, config, env);
addCommonArgs(args, config, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -716,7 +713,9 @@ const repairIndex = async (config: RepositoryConfig) => {
};
};
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
const addCommonArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
args.push("--retry-lock", "1m", "--json");
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,21 +1,21 @@
{
"name": "Zerobyte",
"short_name": "Zerobyte",
"icons": [
{
"src": "/images/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/images/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#1b1b1b",
"background_color": "#1b1b1b",
"display": "standalone"
}
"name": "Zerobyte",
"short_name": "Zerobyte",
"icons": [
{
"src": "/images/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/images/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#1b1b1b",
"background_color": "#1b1b1b",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 28 KiB