Compare commits

..

2 Commits

Author SHA1 Message Date
Nicolas Meienberger
150b4f6e89 chore: format files 2025-11-26 22:52:05 +01:00
Nicolas Meienberger
60f37076a8 refactor: change all timestamps to be in seconds 2025-11-26 22:50:52 +01:00
37 changed files with 1001 additions and 1956 deletions

View File

@@ -709,7 +709,7 @@ export type ListRepositoriesResponses = {
* List of repositories * List of repositories
*/ */
200: Array<{ 200: Array<{
compressionMode: 'auto' | 'max' | 'off' | null; compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -849,7 +849,7 @@ export type CreateRepositoryData = {
isExistingRepository?: boolean; isExistingRepository?: boolean;
}; };
name: string; name: string;
compressionMode?: 'auto' | 'max' | 'off'; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
}; };
path?: never; path?: never;
query?: never; query?: never;
@@ -924,7 +924,7 @@ export type GetRepositoryResponses = {
* Repository details * Repository details
*/ */
200: { 200: {
compressionMode: 'auto' | 'max' | 'off' | null; compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -1002,7 +1002,7 @@ export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryRe
export type UpdateRepositoryData = { export type UpdateRepositoryData = {
body?: { body?: {
compressionMode?: 'auto' | 'max' | 'off'; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
name?: string; name?: string;
}; };
path: { path: {
@@ -1028,7 +1028,7 @@ export type UpdateRepositoryResponses = {
* Repository updated successfully * Repository updated successfully
*/ */
200: { 200: {
compressionMode: 'auto' | 'max' | 'off' | null; compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -1224,8 +1224,6 @@ export type RestoreSnapshotData = {
exclude?: Array<string>; exclude?: Array<string>;
excludeXattr?: Array<string>; excludeXattr?: Array<string>;
include?: Array<string>; include?: Array<string>;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
targetPath?: string;
}; };
path: { path: {
name: string; name: string;
@@ -1297,7 +1295,7 @@ export type ListBackupSchedulesResponses = {
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null; nextBackupAt: number | null;
repository: { repository: {
compressionMode: 'auto' | 'max' | 'off' | null; compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -1530,7 +1528,7 @@ export type GetBackupScheduleResponses = {
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null; nextBackupAt: number | null;
repository: { repository: {
compressionMode: 'auto' | 'max' | 'off' | null; compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -1744,7 +1742,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null; nextBackupAt: number | null;
repository: { repository: {
compressionMode: 'auto' | 'max' | 'off' | null; compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';

View File

@@ -115,6 +115,8 @@ export const CreateRepositoryForm = ({
onChange={(e) => field.onChange(slugify(e.target.value))} onChange={(e) => field.onChange(slugify(e.target.value))}
max={32} max={32}
min={2} min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/> />
</FormControl> </FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription> <FormDescription>Unique identifier for the repository.</FormDescription>
@@ -174,8 +176,10 @@ export const CreateRepositoryForm = ({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="off">Off</SelectItem> <SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto (fast)</SelectItem> <SelectItem value="auto">Auto</SelectItem>
<SelectItem value="max">Max (slower, better compression)</SelectItem> <SelectItem value="fastest">Fastest</SelectItem>
<SelectItem value="better">Better</SelectItem>
<SelectItem value="max">Max</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription> <FormDescription>Compression mode for backups stored in this repository.</FormDescription>

View File

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

View File

@@ -148,13 +148,13 @@ export const ScheduleSummary = (props: Props) => {
<div> <div>
<p className="text-xs uppercase text-muted-foreground">Last backup</p> <p className="text-xs uppercase text-muted-foreground">Last backup</p>
<p className="font-medium"> <p className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleString() : "Never"} {schedule.lastBackupAt ? new Date(schedule.lastBackupAt * 1000).toLocaleString() : "Never"}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs uppercase text-muted-foreground">Next backup</p> <p className="text-xs uppercase text-muted-foreground">Next backup</p>
<p className="font-medium"> <p className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleString() : "Never"} {schedule.nextBackupAt ? new Date(schedule.nextBackupAt * 1000).toLocaleString() : "Never"}
</p> </p>
</div> </div>

View File

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

View File

@@ -97,13 +97,13 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last backup</span> <span className="text-muted-foreground">Last backup</span>
<span className="font-medium"> <span className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"} {schedule.lastBackupAt ? new Date(schedule.lastBackupAt * 1000).toLocaleDateString() : "Never"}
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Next backup</span> <span className="text-muted-foreground">Next backup</span>
<span className="font-medium"> <span className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"} {schedule.nextBackupAt ? new Date(schedule.nextBackupAt * 1000).toLocaleDateString() : "N/A"}
</span> </span>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,169 +1,63 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { useNavigate } from "react-router";
import { Card } from "~/client/components/ui/card"; import { Card } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import type { Repository } from "~/client/lib/types"; import type { Repository } from "~/client/lib/types";
import { slugify } from "~/client/lib/utils";
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { UpdateRepositoryResponse } from "~/client/api-client/types.gen";
import type { CompressionMode } from "~/schemas/restic";
type Props = { type Props = {
repository: Repository; repository: Repository;
}; };
export const RepositoryInfoTabContent = ({ repository }: Props) => { export const RepositoryInfoTabContent = ({ repository }: Props) => {
const navigate = useNavigate();
const [name, setName] = useState(repository.name);
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
(repository.compressionMode as CompressionMode) || "off",
);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const updateMutation = useMutation({
...updateRepositoryMutation(),
onSuccess: (data: UpdateRepositoryResponse) => {
toast.success("Repository updated successfully");
setShowConfirmDialog(false);
if (data.name !== repository.name) {
navigate(`/repositories/${data.name}`);
}
},
onError: (error) => {
toast.error("Failed to update repository", { description: error.message, richColors: true });
setShowConfirmDialog(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setShowConfirmDialog(true);
};
const confirmUpdate = () => {
updateMutation.mutate({
path: { name: repository.name },
body: { name, compressionMode },
});
};
const hasChanges =
name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off");
return ( return (
<> <Card className="p-6">
<Card className="p-6"> <div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-6"> <div>
<div> <h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<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="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">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>
{repository.lastError && (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="text-sm font-medium text-muted-foreground">Name</div>
<h3 className="text-lg font-semibold text-red-500">Last Error</h3> <p className="mt-1 text-sm">{repository.name}</p>
</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>
)} <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 * 1000).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
{repository.lastError && (
<div> <div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3> <div className="flex items-center justify-between mb-4">
<div className="bg-muted/50 rounded-md p-4"> <h3 className="text-lg font-semibold text-red-500">Last Error</h3>
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre> </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> </div>
)}
<div className="flex justify-end pt-4 border-t"> <div>
<Button type="submit" disabled={!hasChanges || updateMutation.isPending} loading={updateMutation.isPending}> <h3 className="text-lg font-semibold mb-4">Configuration</h3>
Save Changes <div className="bg-muted/50 rounded-md p-4">
</Button> <pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div> </div>
</form> </div>
</Card> </div>
</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

@@ -13,7 +13,7 @@ type Props = {
}; };
export const HealthchecksCard = ({ volume }: Props) => { export const HealthchecksCard = ({ volume }: Props) => {
const timeAgo = formatDistanceToNow(volume.lastHealthCheck, { const timeAgo = formatDistanceToNow(volume.lastHealthCheck * 1000, {
addSuffix: true, addSuffix: true,
}); });

View File

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

View File

@@ -1,47 +0,0 @@
-- 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,46 @@
-- Custom SQL migration file, put your code below! --
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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -93,6 +93,8 @@ export type RepositoryConfig = typeof repositoryConfigSchema.infer;
export const COMPRESSION_MODES = { export const COMPRESSION_MODES = {
off: "off", off: "off",
auto: "auto", auto: "auto",
fastest: "fastest",
better: "better",
max: "max", max: "max",
} as const; } as const;
@@ -105,12 +107,3 @@ export const REPOSITORY_STATUS = {
} as const; } as const;
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS; export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
export const OVERWRITE_MODES = {
always: "always",
ifChanged: "if-changed",
ifNewer: "if-newer",
never: "never",
} as const;
export type OverwriteMode = (typeof OVERWRITE_MODES)[keyof typeof OVERWRITE_MODES];

View File

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

View File

@@ -67,7 +67,7 @@ export const authController = new Hono()
setCookie(c, COOKIE_NAME, sessionId, { setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS, ...COOKIE_OPTIONS,
expires: new Date(expiresAt), expires: new Date(expiresAt * 1000),
}); });
return c.json<LoginDto>({ return c.json<LoginDto>({

View File

@@ -3,7 +3,7 @@ import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema"; import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds const SESSION_DURATION = 60 * 60 * 24 * 30; // 30 days in seconds
export class AuthService { export class AuthService {
/** /**
@@ -30,7 +30,7 @@ export class AuthService {
logger.info(`User registered: ${username}`); logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
const expiresAt = Date.now() + SESSION_DURATION; const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
await db.insert(sessionsTable).values({ await db.insert(sessionsTable).values({
id: sessionId, id: sessionId,
@@ -66,7 +66,7 @@ export class AuthService {
} }
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
const expiresAt = Date.now() + SESSION_DURATION; const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
await db.insert(sessionsTable).values({ await db.insert(sessionsTable).values({
id: sessionId, id: sessionId,
@@ -112,7 +112,7 @@ export class AuthService {
return null; return null;
} }
if (session.session.expiresAt < Date.now()) { if (session.session.expiresAt < Math.floor(Date.now() / 1000)) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId)); await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null; return null;
} }
@@ -134,7 +134,10 @@ export class AuthService {
* Clean up expired sessions * Clean up expired sessions
*/ */
async cleanupExpiredSessions() { async cleanupExpiredSessions() {
const result = await db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, Date.now())).returning(); const result = await db
.delete(sessionsTable)
.where(lt(sessionsTable.expiresAt, Math.floor(Date.now() / 1000)))
.returning();
if (result.length > 0) { if (result.length > 0) {
logger.info(`Cleaned up ${result.length} expired sessions`); logger.info(`Cleaned up ${result.length} expired sessions`);
} }

View File

@@ -21,12 +21,12 @@ const calculateNextRun = (cronExpression: string): number => {
tz: Intl.DateTimeFormat().resolvedOptions().timeZone, tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
}); });
return interval.next().getTime(); return Math.floor(interval.next().getTime() / 1000);
} catch (error) { } catch (error) {
logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`); logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`);
const fallback = new Date(); const fallback = new Date();
fallback.setMinutes(fallback.getMinutes() + 1); fallback.setMinutes(fallback.getMinutes() + 1);
return fallback.getTime(); return Math.floor(fallback.getTime() / 1000);
} }
}; };
@@ -126,7 +126,7 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
const [updated] = await db const [updated] = await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ ...data, nextBackupAt, updatedAt: Date.now() }) .set({ ...data, nextBackupAt, updatedAt: Math.floor(Date.now() / 1000) })
.where(eq(backupSchedulesTable.id, scheduleId)) .where(eq(backupSchedulesTable.id, scheduleId))
.returning(); .returning();
@@ -211,7 +211,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ .set({
lastBackupStatus: "in_progress", lastBackupStatus: "in_progress",
updatedAt: Date.now(), updatedAt: Math.floor(Date.now() / 1000),
lastBackupError: null, lastBackupError: null,
nextBackupAt, nextBackupAt,
}) })
@@ -262,11 +262,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await db await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ .set({
lastBackupAt: Date.now(), lastBackupAt: Math.floor(Date.now() / 1000),
lastBackupStatus: exitCode === 0 ? "success" : "warning", lastBackupStatus: exitCode === 0 ? "success" : "warning",
lastBackupError: null, lastBackupError: null,
nextBackupAt: nextBackupAt, nextBackupAt: nextBackupAt,
updatedAt: Date.now(), updatedAt: Math.floor(Date.now() / 1000),
}) })
.where(eq(backupSchedulesTable.id, scheduleId)); .where(eq(backupSchedulesTable.id, scheduleId));
@@ -297,10 +297,10 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await db await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ .set({
lastBackupAt: Date.now(), lastBackupAt: Math.floor(Date.now() / 1000),
lastBackupStatus: "error", lastBackupStatus: "error",
lastBackupError: toMessage(error), lastBackupError: toMessage(error),
updatedAt: Date.now(), updatedAt: Math.floor(Date.now() / 1000),
}) })
.where(eq(backupSchedulesTable.id, scheduleId)); .where(eq(backupSchedulesTable.id, scheduleId));
@@ -328,7 +328,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
}; };
const getSchedulesToExecute = async () => { const getSchedulesToExecute = async () => {
const now = Date.now(); const now = Math.floor(Date.now() / 1000);
const schedules = await db.query.backupSchedulesTable.findMany({ const schedules = await db.query.backupSchedulesTable.findMany({
where: eq(backupSchedulesTable.enabled, true), where: eq(backupSchedulesTable.enabled, true),
}); });
@@ -367,7 +367,7 @@ const stopBackup = async (scheduleId: number) => {
.set({ .set({
lastBackupStatus: "error", lastBackupStatus: "error",
lastBackupError: "Backup was stopped by user", lastBackupError: "Backup was stopped by user",
updatedAt: Date.now(), updatedAt: Math.floor(Date.now() / 1000),
}) })
.where(eq(backupSchedulesTable.id, scheduleId)); .where(eq(backupSchedulesTable.id, scheduleId));

View File

@@ -8,7 +8,7 @@ const MIGRATION_KEY_PREFIX = "migration:";
export const recordMigrationCheckpoint = async (version: string): Promise<void> => { export const recordMigrationCheckpoint = async (version: string): Promise<void> => {
const key = `${MIGRATION_KEY_PREFIX}${version}`; const key = `${MIGRATION_KEY_PREFIX}${version}`;
const now = Date.now(); const now = Math.floor(Date.now() / 1000);
await db await db
.insert(appMetadataTable) .insert(appMetadataTable)

View File

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

View File

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

View File

@@ -157,7 +157,7 @@ const updateDestination = async (
} }
const updateData: Partial<NotificationDestination> = { const updateData: Partial<NotificationDestination> = {
updatedAt: Date.now(), updatedAt: Math.floor(Date.now() / 1000),
}; };
if (updates.name !== undefined) { if (updates.name !== undefined) {

View File

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

View File

@@ -8,7 +8,7 @@ import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id"; import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto"; import { cryptoUtils } from "../../utils/crypto";
import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
const listRepositories = async () => { const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({}); const repositories = await db.query.repositoriesTable.findMany({});
@@ -105,7 +105,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
if (!error) { if (!error) {
await db await db
.update(repositoriesTable) .update(repositoriesTable)
.set({ status: "healthy", lastChecked: Date.now(), lastError: null }) .set({ status: "healthy", lastChecked: Math.floor(Date.now() / 1000), lastError: null })
.where(eq(repositoriesTable.id, id)); .where(eq(repositoriesTable.id, id));
return { repository: created, status: 201 }; return { repository: created, status: 201 };
@@ -201,14 +201,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
const restoreSnapshot = async ( const restoreSnapshot = async (
name: string, name: string,
snapshotId: string, snapshotId: string,
options?: { options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean },
include?: string[];
exclude?: string[];
excludeXattr?: string[];
delete?: boolean;
targetPath?: string;
overwrite?: OverwriteMode;
},
) => { ) => {
const repository = await db.query.repositoriesTable.findFirst({ const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name), where: eq(repositoriesTable.name, name),
@@ -218,9 +211,7 @@ const restoreSnapshot = async (
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const target = options?.targetPath || "/"; const result = await restic.restore(repository.config, snapshotId, "/", options);
const result = await restic.restore(repository.config, snapshotId, target, options);
return { return {
success: true, success: true,
@@ -258,18 +249,21 @@ const checkHealth = async (repositoryId: string) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const { hasErrors, error } = await restic.check(repository.config); const { error, status } = await restic
.snapshots(repository.config)
.then(() => ({ error: null, status: "healthy" as const }))
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
await db await db
.update(repositoriesTable) .update(repositoriesTable)
.set({ .set({
status: hasErrors ? "error" : "healthy", status,
lastChecked: Date.now(), lastChecked: Math.floor(Date.now() / 1000),
lastError: error, lastError: error,
}) })
.where(eq(repositoriesTable.id, repository.id)); .where(eq(repositoriesTable.id, repository.id));
return { lastError: error }; return { status, lastError: error };
}; };
const doctorRepository = async (name: string) => { const doctorRepository = async (name: string) => {
@@ -341,7 +335,7 @@ const doctorRepository = async (name: string) => {
.update(repositoriesTable) .update(repositoriesTable)
.set({ .set({
status: allSuccessful ? "healthy" : "error", status: allSuccessful ? "healthy" : "error",
lastChecked: Date.now(), lastChecked: Math.floor(Date.now() / 1000),
lastError: allSuccessful ? null : steps.find((s) => !s.success)?.error, lastError: allSuccessful ? null : steps.find((s) => !s.success)?.error,
}) })
.where(eq(repositoriesTable.id, repository.id)); .where(eq(repositoriesTable.id, repository.id));
@@ -402,7 +396,7 @@ const updateRepository = async (name: string, updates: { name?: string; compress
.set({ .set({
name: newName, name: newName,
compressionMode: updates.compressionMode ?? existing.compressionMode, compressionMode: updates.compressionMode ?? existing.compressionMode,
updatedAt: Date.now(), updatedAt: Math.floor(Date.now() / 1000),
}) })
.where(eq(repositoriesTable.id, existing.id)) .where(eq(repositoriesTable.id, existing.id))
.returning(); .returning();

View File

@@ -57,7 +57,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
await db await db
.update(volumesTable) .update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() }) .set({ status, lastError: error ?? null, lastHealthCheck: Math.floor(Date.now() / 1000) })
.where(eq(volumesTable.name, slug)); .where(eq(volumesTable.name, slug));
return { volume: created, status: 201 }; return { volume: created, status: 201 };
@@ -91,7 +91,7 @@ const mountVolume = async (name: string) => {
await db await db
.update(volumesTable) .update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() }) .set({ status, lastError: error ?? null, lastHealthCheck: Math.floor(Date.now() / 1000) })
.where(eq(volumesTable.name, name)); .where(eq(volumesTable.name, name));
if (status === "mounted") { if (status === "mounted") {
@@ -196,7 +196,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const { error, status } = await backend.mount(); const { error, status } = await backend.mount();
await db await db
.update(volumesTable) .update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() }) .set({ status, lastError: error ?? null, lastHealthCheck: Math.floor(Date.now() / 1000) })
.where(eq(volumesTable.id, existing.id)); .where(eq(volumesTable.id, existing.id));
serverEvents.emit("volume:updated", { volumeName: updated.name }); serverEvents.emit("volume:updated", { volumeName: updated.name });
@@ -255,7 +255,7 @@ const checkHealth = async (name: string) => {
await db await db
.update(volumesTable) .update(volumesTable)
.set({ lastHealthCheck: Date.now(), status, lastError: error ?? null }) .set({ lastHealthCheck: Math.floor(Date.now() / 1000), status, lastError: error ?? null })
.where(eq(volumesTable.name, volume.name)); .where(eq(volumesTable.name, volume.name));
return { status, error }; return { status, error };

View File

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

View File

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

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: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.3 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: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 21 KiB