feat: edit repository form (#507)

* feat: edit repository form

* refactor: local repo path concat as a code migration

* refactor: server constants

* chore: fix lint issue in test file

* refactor: add auth to getServerConstants
This commit is contained in:
Nico 2026-02-14 11:49:33 +01:00 committed by GitHub
parent 60c8bd77f5
commit 1017f1a38b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1037 additions and 374 deletions

1
.gitignore vendored
View file

@ -37,3 +37,4 @@ playwright/temp
# OpenAPI error logs
openapi-ts-error-*.log
.output
tmp/

View file

@ -841,7 +841,7 @@ export type ListRepositoriesResponses = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -851,7 +851,6 @@ export type ListRepositoriesResponses = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
@ -1034,7 +1033,7 @@ export type CreateRepositoryData = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -1044,7 +1043,6 @@ export type CreateRepositoryData = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
@ -1279,7 +1277,7 @@ export type GetRepositoryResponses = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -1289,7 +1287,6 @@ export type GetRepositoryResponses = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
@ -1387,6 +1384,172 @@ export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryRe
export type UpdateRepositoryData = {
body?: {
compressionMode?: "auto" | "max" | "off";
config?:
| {
accessKeyId: string;
backend: "r2";
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
insecureTls?: boolean;
isExistingRepository?: boolean;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
}
| {
accessKeyId: string;
backend: "s3";
bucket: string;
endpoint: string;
secretAccessKey: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
insecureTls?: boolean;
isExistingRepository?: boolean;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
}
| {
accountKey: string;
accountName: string;
backend: "azure";
container: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
endpointSuffix?: string;
insecureTls?: boolean;
isExistingRepository?: boolean;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
}
| {
backend: "gcs";
bucket: string;
credentialsJson: string;
projectId: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
insecureTls?: boolean;
isExistingRepository?: boolean;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
}
| {
backend: "local";
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
insecureTls?: boolean;
isExistingRepository?: boolean;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
}
| {
backend: "rclone";
path: string;
remote: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
insecureTls?: boolean;
isExistingRepository?: boolean;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
}
| {
backend: "rest";
url: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
insecureTls?: boolean;
isExistingRepository?: boolean;
password?: string;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
username?: string;
}
| {
backend: "sftp";
host: string;
path: string;
privateKey: string;
user: string;
port?: number;
skipHostKeyCheck?: boolean;
cacert?: string;
customPassword?: string;
downloadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
insecureTls?: boolean;
isExistingRepository?: boolean;
knownHosts?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
enabled?: boolean;
};
};
name?: string;
};
path: {
@ -1397,6 +1560,10 @@ export type UpdateRepositoryData = {
};
export type UpdateRepositoryErrors = {
/**
* Invalid repository update payload
*/
400: unknown;
/**
* Repository not found
*/
@ -1499,7 +1666,7 @@ export type UpdateRepositoryResponses = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -1509,7 +1676,6 @@ export type UpdateRepositoryResponses = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
@ -2077,7 +2243,7 @@ export type ListBackupSchedulesResponses = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -2087,7 +2253,6 @@ export type ListBackupSchedulesResponses = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
@ -2460,7 +2625,7 @@ export type GetBackupScheduleResponses = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -2470,7 +2635,6 @@ export type GetBackupScheduleResponses = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
@ -2824,7 +2988,7 @@ export type GetBackupScheduleForVolumeResponses = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -2834,7 +2998,6 @@ export type GetBackupScheduleForVolumeResponses = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
@ -3400,7 +3563,7 @@ export type GetScheduleMirrorsResponses = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -3410,7 +3573,6 @@ export type GetScheduleMirrorsResponses = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;
@ -3620,7 +3782,7 @@ export type UpdateScheduleMirrorsResponses = {
}
| {
backend: "local";
name: string;
path: string;
cacert?: string;
customPassword?: string;
downloadLimit?: {
@ -3630,7 +3792,6 @@ export type UpdateScheduleMirrorsResponses = {
};
insecureTls?: boolean;
isExistingRepository?: boolean;
path?: string;
uploadLimit?: {
unit?: "Gbps" | "Kbps" | "Mbps";
value?: number;

View file

@ -172,8 +172,8 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
<div className="flex items-center gap-2">
<Link
hidden={!backup}
to={backup ? `/backups/$scheduleId` : "."}
params={backup ? { scheduleId: String(backup.id) } : {}}
to={backup ? `/backups/$backupId` : "."}
params={backup ? { backupId: String(backup.id) } : {}}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>

View file

@ -1,2 +0,0 @@
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
export const REGISTRATION_ENABLED_KEY = "registrations_enabled";

View file

@ -7,7 +7,7 @@ import { Link } from "@tanstack/react-router";
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
return (
<Link key={schedule.id} to="/backups/$scheduleId" params={{ scheduleId: schedule.id.toString() }}>
<Link key={schedule.id} to="/backups/$backupId" params={{ backupId: schedule.id.toString() }}>
<Card key={schedule.id} className="flex flex-col h-full">
<CardHeader className="pb-3 overflow-hidden">
<div className="flex items-center justify-between gap-2 w-full">

View file

@ -95,11 +95,11 @@ export const SnapshotFileBrowser = (props: Props) => {
</div>
<div className="flex gap-2">
<Link
to={backupId ? "/backups/$backupId/$snapshotId/restore" : "/repositories/$repoId/$snapId/restore"}
to={backupId ? "/backups/$backupId/$snapshotId/restore" : "/repositories/$repositoryId/$snapshotId/restore"}
params={
backupId
? { backupId, snapshotId: snapshot.short_id }
: { repoId: repositoryId, snapId: snapshot.short_id }
: { repositoryId: repositoryId, snapshotId: snapshot.short_id }
}
className={buttonVariants({ variant: "primary", size: "sm" })}
>

View file

@ -33,6 +33,9 @@ import {
SftpRepositoryForm,
AdvancedForm,
} from "./repository-forms";
import { useServerFn } from "@tanstack/react-start";
import { getServerConstants } from "~/server/lib/functions/server-constants";
import { useSuspenseQuery } from "@tanstack/react-query";
export const formSchema = type({
name: "2<=string<=32",
@ -51,8 +54,8 @@ type Props = {
className?: string;
};
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
const defaultValuesForType = (repoBase: string) => ({
local: { backend: "local" as const, compressionMode: "auto" as const, path: repoBase },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
r2: { backend: "r2" as const, compressionMode: "auto" as const },
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
@ -60,7 +63,7 @@ const defaultValuesForType = {
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const },
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false },
};
});
export const CreateRepositoryForm = ({
onSubmit,
@ -70,6 +73,12 @@ export const CreateRepositoryForm = ({
loading,
className,
}: Props) => {
const getConstants = useServerFn(getServerConstants);
const { data: constants } = useSuspenseQuery({
queryKey: ["server-constants"],
queryFn: getConstants,
});
const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
@ -124,10 +133,13 @@ export const CreateRepositoryForm = ({
name: form.getValues().name,
isExistingRepository: form.getValues().isExistingRepository,
customPassword: form.getValues().customPassword,
...defaultValuesForType[value as keyof typeof defaultValuesForType],
...defaultValuesForType(constants.REPOSITORY_BASE)[
value as keyof ReturnType<typeof defaultValuesForType>
],
});
}}
value={field.value}
disabled={mode === "update"}
>
<FormControl>
<SelectTrigger>
@ -192,6 +204,7 @@ export const CreateRepositoryForm = ({
<FormControl>
<Checkbox
checked={field.value}
disabled={mode === "update"}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {

View file

@ -1,7 +1,6 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
import { REPOSITORY_BASE } from "~/client/lib/constants";
import { Button } from "../../../../components/ui/button";
import { FormItem, FormLabel, FormDescription, FormField, FormControl } from "../../../../components/ui/form";
import { DirectoryBrowser } from "../../../../components/directory-browser";
@ -16,6 +15,9 @@ import {
AlertDialogTitle,
} from "../../../../components/ui/alert-dialog";
import type { RepositoryFormValues } from "../create-repository-form";
import { useServerFn } from "@tanstack/react-start";
import { getServerConstants } from "~/server/lib/functions/server-constants";
import { useSuspenseQuery } from "@tanstack/react-query";
type Props = {
form: UseFormReturn<RepositoryFormValues>;
@ -25,6 +27,12 @@ export const LocalRepositoryForm = ({ form }: Props) => {
const [showPathBrowser, setShowPathBrowser] = useState(false);
const [showPathWarning, setShowPathWarning] = useState(false);
const getConstants = useServerFn(getServerConstants);
const { data: constants } = useSuspenseQuery({
queryKey: ["server-constants"],
queryFn: getConstants,
});
return (
<FormField
control={form.control}
@ -36,7 +44,7 @@ export const LocalRepositoryForm = ({ form }: Props) => {
<FormControl>
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
{field.value || REPOSITORY_BASE}
{field.value || constants.REPOSITORY_BASE}
</div>
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
<Pencil className="h-4 w-4 mr-2" />
@ -60,8 +68,8 @@ export const LocalRepositoryForm = ({ form }: Props) => {
If the path is not a host mount, you will lose your repository data when the container restarts.
</p>
<p className="text-sm text-muted-foreground">
The default path <code className="bg-muted px-1 rounded">{REPOSITORY_BASE}</code> is safe to use if
you followed the recommended Docker Compose setup.
The default path <code className="bg-muted px-1 rounded">{constants.REPOSITORY_BASE}</code> is safe
to use if you followed the recommended Docker Compose setup.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
@ -92,7 +100,7 @@ export const LocalRepositoryForm = ({ form }: Props) => {
onSelectPath={(path) => {
field.onChange(path);
}}
selectedPath={field.value || REPOSITORY_BASE}
selectedPath={field.value || constants.REPOSITORY_BASE}
/>
</div>
<AlertDialogFooter>

View file

@ -0,0 +1,176 @@
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Database } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import {
CreateRepositoryForm,
type RepositoryFormValues,
} from "~/client/modules/repositories/components/create-repository-form";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import type { RepositoryConfig } from "~/schemas/restic";
import { useNavigate } from "@tanstack/react-router";
const riskyLocationFieldsByBackend = {
local: ["path"],
s3: ["endpoint", "bucket"],
r2: ["endpoint", "bucket"],
gcs: ["bucket", "projectId"],
azure: ["container", "accountName", "endpointSuffix"],
rest: ["url", "path"],
sftp: ["host", "port", "path", "user"],
rclone: ["remote", "path"],
} as const;
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => {
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
return fields.some(
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryFormValues],
);
};
export function EditRepositoryPage({ repositoryId }: { repositoryId: string }) {
const navigate = useNavigate();
const [showRiskConfirm, setShowRiskConfirm] = useState(false);
const [pendingValues, setPendingValues] = useState<RepositoryFormValues | null>(null);
const { data: repository } = useSuspenseQuery({
...getRepositoryOptions({ path: { id: repositoryId } }),
});
const updateRepository = useMutation({
...updateRepositoryMutation(),
onSuccess: async (data) => {
toast.success("Repository updated successfully");
setShowRiskConfirm(false);
setPendingValues(null);
void navigate({ to: `/repositories/${data.shortId}` });
},
onError: (error) => {
toast.error("Failed to update repository", {
description: parseError(error)?.message,
});
},
});
const initialConfig = repository.config as RepositoryConfig;
const initialValues: RepositoryFormValues = {
...initialConfig,
name: repository.name,
compressionMode: repository.compressionMode ?? "auto",
};
const submitUpdate = (values: RepositoryFormValues) => {
updateRepository.mutate({
path: { id: repositoryId },
body: {
name: values.name,
compressionMode: values.compressionMode,
config: values,
},
});
};
const handleSubmit = (values: RepositoryFormValues) => {
const nextConfig = values;
if (hasRiskyLocationChange(initialConfig, nextConfig)) {
setPendingValues(values);
setShowRiskConfirm(true);
return;
}
submitUpdate(values);
};
const handleSaveAnyway = () => {
if (!pendingValues) {
return;
}
submitUpdate(pendingValues);
};
const handleRiskConfirmOpenChange = (open: boolean) => {
setShowRiskConfirm(open);
if (!open) {
setPendingValues(null);
}
};
return (
<>
<div className="container mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Database className="w-5 h-5 text-primary" />
</div>
<CardTitle>Edit Repository</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{updateRepository.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to update repository:</strong>
<br />
{parseError(updateRepository.error)?.message}
</AlertDescription>
</Alert>
)}
<CreateRepositoryForm
mode="update"
initialValues={initialValues}
onSubmit={handleSubmit}
loading={updateRepository.isPending}
/>
<div className="flex justify-end pt-2">
<Button
type="button"
variant="secondary"
onClick={() => navigate({ to: `/repositories/${repository.shortId}` })}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
</div>
<AlertDialog open={showRiskConfirm} onOpenChange={handleRiskConfirmOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Repository location changed</AlertDialogTitle>
<AlertDialogDescription>
Changing endpoint, bucket, host, or path fields may point to a different repository location. Before
saving, ensure the repository already exists at the new target.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSaveAnyway} disabled={updateRepository.isPending}>
Save anyway
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -8,7 +8,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
export default function RepositoryDetailsPage({ repositoryId }: { repositoryId: string }) {
const navigate = useNavigate();
const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId" });
const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId/" });
const activeTab = tab || "info";
const { data } = useSuspenseQuery({

View file

@ -14,7 +14,7 @@ import { Database } from "lucide-react";
import { Link, useParams } from "@tanstack/react-router";
export const SnapshotError = () => {
const { repoId } = useParams({ from: "/(dashboard)/repositories/$repoId/$snapshotId" });
const { repositoryId } = useParams({ from: "/(dashboard)/repositories/$repositoryId/$snapshotId/" });
return (
<Card>
@ -27,7 +27,7 @@ export const SnapshotError = () => {
<Link
to={`/repositories/$repositoryId`}
search={() => ({ tab: "snapshots" })}
params={{ repositoryId: repoId }}
params={{ repositoryId }}
>
<Button variant="outline">Back to repository</Button>
</Link>
@ -125,9 +125,9 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId
<span className="text-muted-foreground">Backup Schedule:</span>
<p>
<Link
to="/backups/$scheduleId"
to="/backups/$backupId"
className="text-primary hover:underline"
params={{ scheduleId: backupSchedule.shortId }}
params={{ backupId: backupSchedule.shortId }}
>
{backupSchedule?.name}
</Link>

View file

@ -1,33 +1,27 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Check, Save, Square, Stethoscope, Trash2, Unlock } from "lucide-react";
import { Pencil, Square, Stethoscope, Trash2, Unlock } from "lucide-react";
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 { REPOSITORY_BASE } from "~/client/lib/constants";
import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime";
import {
cancelDoctorMutation,
deleteRepositoryMutation,
startDoctorMutation,
unlockRepositoryMutation,
updateRepositoryMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import type { RepositoryConfig } from "~/schemas/restic";
import { DoctorReport } from "../components/doctor-report";
import { parseError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
@ -37,38 +31,16 @@ type Props = {
};
const getEffectiveLocalPath = (repository: Repository): string | null => {
if (repository.type !== "local") return null;
const config = repository.config as { name: string; path?: string; isExistingRepository?: boolean };
if (config.isExistingRepository) {
return config.path ?? null;
}
const basePath = config.path || REPOSITORY_BASE;
return `${basePath}/${config.name}`;
if (repository.config.backend !== "local") return null;
return repository.config.path;
};
export const RepositoryInfoTabContent = ({ repository }: Props) => {
const [name, setName] = useState(repository.name);
const [compressionMode, setCompressionMode] = useState<CompressionMode>(repository.compressionMode || "off");
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const navigate = useNavigate();
const effectiveLocalPath = getEffectiveLocalPath(repository);
const updateMutation = useMutation({
...updateRepositoryMutation(),
onSuccess: () => {
toast.success("Repository updated successfully");
setShowConfirmDialog(false);
},
onError: (error) => {
toast.error("Failed to update repository", { description: error.message, richColors: true });
setShowConfirmDialog(false);
},
});
const deleteRepo = useMutation({
...deleteRepositoryMutation(),
onSuccess: () => {
@ -115,48 +87,40 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
},
});
const handleSubmit = (e: React.SubmitEvent) => {
e.preventDefault();
setShowConfirmDialog(true);
};
const confirmUpdate = () => {
updateMutation.mutate({
path: { id: repository.id },
body: { name, compressionMode },
});
};
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteRepo.mutate({ path: { id: repository.id } });
};
const hasChanges =
name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off");
const config = repository.config as RepositoryConfig;
return (
<>
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
<div>
<span className="text-lg font-semibold mb-4">Repository Settings</span>
</div>
<div className="flex flex-wrap justify-end gap-2 sm:gap-4">
{repository.status === "doctor" ? (
<Button
type="button"
variant="destructive"
loading={cancelDoctor.isPending}
onClick={() => cancelDoctor.mutate({ path: { id: repository.id } })}
>
<Square className="h-4 w-4 mr-2" />
<span>Cancel doctor</span>
</Button>
) : (
<Card className="p-6 space-y-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
<div>
<span className="text-lg font-semibold mb-4">Repository Settings</span>
</div>
<div className="flex flex-wrap justify-end gap-2 sm:gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate({ to: `/repositories/${repository.shortId}/edit` })}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Button>
{repository.status === "doctor" ? (
<Button
type="button"
variant="destructive"
loading={cancelDoctor.isPending}
onClick={() => cancelDoctor.mutate({ path: { id: repository.id } })}
>
<Square className="h-4 w-4 mr-2" />
<span>Cancel doctor</span>
</Button>
) : (
<Button
type="button"
onClick={() => startDoctor.mutate({ path: { id: repository.id } })}
@ -181,132 +145,94 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteRepo.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
<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(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>
<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>
{effectiveLocalPath && (
<div className="md:col-span-2">
<div className="text-sm font-medium text-muted-foreground">Local path</div>
<p className="mt-1 text-sm font-mono">{effectiveLocalPath}</p>
</div>
)}
<div>
<div className="text-sm font-medium text-muted-foreground">Created at</div>
<p className="mt-1 text-sm">{formatDateTime(repository.createdAt)}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last checked</div>
<p className="mt-1 text-sm">{formatTimeAgo(repository.lastChecked)}</p>
</div>
{config.cacert && (
<div>
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
<p className="mt-1 text-sm">
<span className="text-green-500">configured</span>
</p>
</div>
)}
{"insecureTls" in config && (
<div>
<div className="text-sm font-medium text-muted-foreground">TLS Certificate Validation</div>
<p className="mt-1 text-sm">
{config.insecureTls ? (
<span className="text-red-500">disabled</span>
) : (
<span className="text-green-500">enabled</span>
)}
</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>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500 wrap-break-word">{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>
<DoctorReport repositoryStatus={repository.status} result={repository.doctorResult} />
<div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={!hasChanges || updateMutation.isPending} loading={updateMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
Save Changes
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</form>
</Card>
</div>
<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}>
<Check className="h-4 w-4" />
Update
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div>
<h3 className="text-lg font-semibold mb-4">Current Configuration</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">Compression mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</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>
{effectiveLocalPath && (
<div>
<div className="text-sm font-medium text-muted-foreground">Local path</div>
<p className="mt-1 text-sm font-mono">{effectiveLocalPath}</p>
</div>
)}
<div>
<div className="text-sm font-medium text-muted-foreground">Created at</div>
<p className="mt-1 text-sm">{formatDateTime(repository.createdAt)}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last checked</div>
<p className="mt-1 text-sm">{formatTimeAgo(repository.lastChecked)}</p>
</div>
{config.cacert && (
<div>
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
<p className="mt-1 text-sm">
<span className="text-green-500">configured</span>
</p>
</div>
)}
{"insecureTls" in config && (
<div>
<div className="text-sm font-medium text-muted-foreground">TLS Certificate Validation</div>
<p className="mt-1 text-sm">
{config.insecureTls ? (
<span className="text-red-500">disabled</span>
) : (
<span className="text-green-500">enabled</span>
)}
</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>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500 wrap-break-word">{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>
<DoctorReport repositoryStatus={repository.status} result={repository.doctorResult} />
</Card>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>

View file

@ -23,14 +23,15 @@ import { Route as dashboardBackupsIndexRouteImport } from './routes/(dashboard)/
import { Route as dashboardVolumesCreateRouteImport } from './routes/(dashboard)/volumes/create'
import { Route as dashboardVolumesVolumeIdRouteImport } from './routes/(dashboard)/volumes/$volumeId'
import { Route as dashboardRepositoriesCreateRouteImport } from './routes/(dashboard)/repositories/create'
import { Route as dashboardRepositoriesRepositoryIdRouteImport } from './routes/(dashboard)/repositories/$repositoryId'
import { Route as dashboardNotificationsCreateRouteImport } from './routes/(dashboard)/notifications/create'
import { Route as dashboardNotificationsNotificationIdRouteImport } from './routes/(dashboard)/notifications/$notificationId'
import { Route as dashboardBackupsCreateRouteImport } from './routes/(dashboard)/backups/create'
import { Route as dashboardBackupsScheduleIdRouteImport } from './routes/(dashboard)/backups/$scheduleId'
import { Route as dashboardRepositoriesRepoIdSnapshotIdRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapshotId'
import { Route as dashboardRepositoriesRepoIdSnapIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapId.restore'
import { Route as dashboardBackupsBackupIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/backups/$backupId.$snapshotId.restore'
import { Route as dashboardRepositoriesRepositoryIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/index'
import { Route as dashboardBackupsBackupIdIndexRouteImport } from './routes/(dashboard)/backups/$backupId/index'
import { Route as dashboardRepositoriesRepositoryIdEditRouteImport } from './routes/(dashboard)/repositories/$repositoryId/edit'
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/index'
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
import { Route as dashboardBackupsBackupIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/backups/$backupId/$snapshotId.restore'
const dashboardRouteRoute = dashboardRouteRouteImport.update({
id: '/(dashboard)',
@ -105,12 +106,6 @@ const dashboardRepositoriesCreateRoute =
path: '/repositories/create',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepositoryIdRoute =
dashboardRepositoriesRepositoryIdRouteImport.update({
id: '/repositories/$repositoryId',
path: '/repositories/$repositoryId',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardNotificationsCreateRoute =
dashboardNotificationsCreateRouteImport.update({
id: '/notifications/create',
@ -128,22 +123,34 @@ const dashboardBackupsCreateRoute = dashboardBackupsCreateRouteImport.update({
path: '/backups/create',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardBackupsScheduleIdRoute =
dashboardBackupsScheduleIdRouteImport.update({
id: '/backups/$scheduleId',
path: '/backups/$scheduleId',
const dashboardRepositoriesRepositoryIdIndexRoute =
dashboardRepositoriesRepositoryIdIndexRouteImport.update({
id: '/repositories/$repositoryId/',
path: '/repositories/$repositoryId/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepoIdSnapshotIdRoute =
dashboardRepositoriesRepoIdSnapshotIdRouteImport.update({
id: '/repositories/$repoId/$snapshotId',
path: '/repositories/$repoId/$snapshotId',
const dashboardBackupsBackupIdIndexRoute =
dashboardBackupsBackupIdIndexRouteImport.update({
id: '/backups/$backupId/',
path: '/backups/$backupId/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepoIdSnapIdRestoreRoute =
dashboardRepositoriesRepoIdSnapIdRestoreRouteImport.update({
id: '/repositories/$repoId/$snapId/restore',
path: '/repositories/$repoId/$snapId/restore',
const dashboardRepositoriesRepositoryIdEditRoute =
dashboardRepositoriesRepositoryIdEditRouteImport.update({
id: '/repositories/$repositoryId/edit',
path: '/repositories/$repositoryId/edit',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute =
dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport.update({
id: '/repositories/$repositoryId/$snapshotId/',
path: '/repositories/$repositoryId/$snapshotId/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute =
dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport.update({
id: '/repositories/$repositoryId/$snapshotId/restore',
path: '/repositories/$repositoryId/$snapshotId/restore',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardBackupsBackupIdSnapshotIdRestoreRoute =
@ -159,11 +166,9 @@ export interface FileRoutesByFullPath {
'/login': typeof authLoginRoute
'/onboarding': typeof authOnboardingRoute
'/api/$': typeof ApiSplatRoute
'/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute
'/backups/create': typeof dashboardBackupsCreateRoute
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
'/notifications/create': typeof dashboardNotificationsCreateRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/repositories/create': typeof dashboardRepositoriesCreateRoute
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/volumes/create': typeof dashboardVolumesCreateRoute
@ -172,9 +177,12 @@ export interface FileRoutesByFullPath {
'/repositories/': typeof dashboardRepositoriesIndexRoute
'/settings/': typeof dashboardSettingsIndexRoute
'/volumes/': typeof dashboardVolumesIndexRoute
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
'/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
'/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
'/repositories/$repositoryId/$snapshotId/restore': typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute
'/repositories/$repositoryId/$snapshotId/': typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -182,11 +190,9 @@ export interface FileRoutesByTo {
'/login': typeof authLoginRoute
'/onboarding': typeof authOnboardingRoute
'/api/$': typeof ApiSplatRoute
'/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute
'/backups/create': typeof dashboardBackupsCreateRoute
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
'/notifications/create': typeof dashboardNotificationsCreateRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/repositories/create': typeof dashboardRepositoriesCreateRoute
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/volumes/create': typeof dashboardVolumesCreateRoute
@ -195,9 +201,12 @@ export interface FileRoutesByTo {
'/repositories': typeof dashboardRepositoriesIndexRoute
'/settings': typeof dashboardSettingsIndexRoute
'/volumes': typeof dashboardVolumesIndexRoute
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/backups/$backupId': typeof dashboardBackupsBackupIdIndexRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
'/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
'/repositories/$repositoryId/$snapshotId/restore': typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute
'/repositories/$repositoryId/$snapshotId': typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -207,11 +216,9 @@ export interface FileRoutesById {
'/(auth)/login': typeof authLoginRoute
'/(auth)/onboarding': typeof authOnboardingRoute
'/api/$': typeof ApiSplatRoute
'/(dashboard)/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute
'/(dashboard)/backups/create': typeof dashboardBackupsCreateRoute
'/(dashboard)/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
'/(dashboard)/notifications/create': typeof dashboardNotificationsCreateRoute
'/(dashboard)/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/(dashboard)/repositories/create': typeof dashboardRepositoriesCreateRoute
'/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/(dashboard)/volumes/create': typeof dashboardVolumesCreateRoute
@ -220,9 +227,12 @@ export interface FileRoutesById {
'/(dashboard)/repositories/': typeof dashboardRepositoriesIndexRoute
'/(dashboard)/settings/': typeof dashboardSettingsIndexRoute
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
'/(dashboard)/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
'/(dashboard)/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/(dashboard)/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
'/(dashboard)/repositories/$repositoryId/': typeof dashboardRepositoriesRepositoryIdIndexRoute
'/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
'/(dashboard)/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
'/(dashboard)/repositories/$repositoryId/$snapshotId/restore': typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute
'/(dashboard)/repositories/$repositoryId/$snapshotId/': typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -232,11 +242,9 @@ export interface FileRouteTypes {
| '/login'
| '/onboarding'
| '/api/$'
| '/backups/$scheduleId'
| '/backups/create'
| '/notifications/$notificationId'
| '/notifications/create'
| '/repositories/$repositoryId'
| '/repositories/create'
| '/volumes/$volumeId'
| '/volumes/create'
@ -245,9 +253,12 @@ export interface FileRouteTypes {
| '/repositories/'
| '/settings/'
| '/volumes/'
| '/repositories/$repoId/$snapshotId'
| '/repositories/$repositoryId/edit'
| '/backups/$backupId/'
| '/repositories/$repositoryId/'
| '/backups/$backupId/$snapshotId/restore'
| '/repositories/$repoId/$snapId/restore'
| '/repositories/$repositoryId/$snapshotId/restore'
| '/repositories/$repositoryId/$snapshotId/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -255,11 +266,9 @@ export interface FileRouteTypes {
| '/login'
| '/onboarding'
| '/api/$'
| '/backups/$scheduleId'
| '/backups/create'
| '/notifications/$notificationId'
| '/notifications/create'
| '/repositories/$repositoryId'
| '/repositories/create'
| '/volumes/$volumeId'
| '/volumes/create'
@ -268,9 +277,12 @@ export interface FileRouteTypes {
| '/repositories'
| '/settings'
| '/volumes'
| '/repositories/$repoId/$snapshotId'
| '/repositories/$repositoryId/edit'
| '/backups/$backupId'
| '/repositories/$repositoryId'
| '/backups/$backupId/$snapshotId/restore'
| '/repositories/$repoId/$snapId/restore'
| '/repositories/$repositoryId/$snapshotId/restore'
| '/repositories/$repositoryId/$snapshotId'
id:
| '__root__'
| '/'
@ -279,11 +291,9 @@ export interface FileRouteTypes {
| '/(auth)/login'
| '/(auth)/onboarding'
| '/api/$'
| '/(dashboard)/backups/$scheduleId'
| '/(dashboard)/backups/create'
| '/(dashboard)/notifications/$notificationId'
| '/(dashboard)/notifications/create'
| '/(dashboard)/repositories/$repositoryId'
| '/(dashboard)/repositories/create'
| '/(dashboard)/volumes/$volumeId'
| '/(dashboard)/volumes/create'
@ -292,9 +302,12 @@ export interface FileRouteTypes {
| '/(dashboard)/repositories/'
| '/(dashboard)/settings/'
| '/(dashboard)/volumes/'
| '/(dashboard)/repositories/$repoId/$snapshotId'
| '/(dashboard)/repositories/$repositoryId/edit'
| '/(dashboard)/backups/$backupId/'
| '/(dashboard)/repositories/$repositoryId/'
| '/(dashboard)/backups/$backupId/$snapshotId/restore'
| '/(dashboard)/repositories/$repoId/$snapId/restore'
| '/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
| '/(dashboard)/repositories/$repositoryId/$snapshotId/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -406,13 +419,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardRepositoriesCreateRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repositoryId': {
id: '/(dashboard)/repositories/$repositoryId'
path: '/repositories/$repositoryId'
fullPath: '/repositories/$repositoryId'
preLoaderRoute: typeof dashboardRepositoriesRepositoryIdRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/notifications/create': {
id: '/(dashboard)/notifications/create'
path: '/notifications/create'
@ -434,25 +440,39 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardBackupsCreateRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/backups/$scheduleId': {
id: '/(dashboard)/backups/$scheduleId'
path: '/backups/$scheduleId'
fullPath: '/backups/$scheduleId'
preLoaderRoute: typeof dashboardBackupsScheduleIdRouteImport
'/(dashboard)/repositories/$repositoryId/': {
id: '/(dashboard)/repositories/$repositoryId/'
path: '/repositories/$repositoryId'
fullPath: '/repositories/$repositoryId/'
preLoaderRoute: typeof dashboardRepositoriesRepositoryIdIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repoId/$snapshotId': {
id: '/(dashboard)/repositories/$repoId/$snapshotId'
path: '/repositories/$repoId/$snapshotId'
fullPath: '/repositories/$repoId/$snapshotId'
preLoaderRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRouteImport
'/(dashboard)/backups/$backupId/': {
id: '/(dashboard)/backups/$backupId/'
path: '/backups/$backupId'
fullPath: '/backups/$backupId/'
preLoaderRoute: typeof dashboardBackupsBackupIdIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repoId/$snapId/restore': {
id: '/(dashboard)/repositories/$repoId/$snapId/restore'
path: '/repositories/$repoId/$snapId/restore'
fullPath: '/repositories/$repoId/$snapId/restore'
preLoaderRoute: typeof dashboardRepositoriesRepoIdSnapIdRestoreRouteImport
'/(dashboard)/repositories/$repositoryId/edit': {
id: '/(dashboard)/repositories/$repositoryId/edit'
path: '/repositories/$repositoryId/edit'
fullPath: '/repositories/$repositoryId/edit'
preLoaderRoute: typeof dashboardRepositoriesRepositoryIdEditRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repositoryId/$snapshotId/': {
id: '/(dashboard)/repositories/$repositoryId/$snapshotId/'
path: '/repositories/$repositoryId/$snapshotId'
fullPath: '/repositories/$repositoryId/$snapshotId/'
preLoaderRoute: typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repositoryId/$snapshotId/restore': {
id: '/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
path: '/repositories/$repositoryId/$snapshotId/restore'
fullPath: '/repositories/$repositoryId/$snapshotId/restore'
preLoaderRoute: typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/backups/$backupId/$snapshotId/restore': {
@ -466,11 +486,9 @@ declare module '@tanstack/react-router' {
}
interface dashboardRouteRouteChildren {
dashboardBackupsScheduleIdRoute: typeof dashboardBackupsScheduleIdRoute
dashboardBackupsCreateRoute: typeof dashboardBackupsCreateRoute
dashboardNotificationsNotificationIdRoute: typeof dashboardNotificationsNotificationIdRoute
dashboardNotificationsCreateRoute: typeof dashboardNotificationsCreateRoute
dashboardRepositoriesRepositoryIdRoute: typeof dashboardRepositoriesRepositoryIdRoute
dashboardRepositoriesCreateRoute: typeof dashboardRepositoriesCreateRoute
dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute
dashboardVolumesCreateRoute: typeof dashboardVolumesCreateRoute
@ -479,19 +497,19 @@ interface dashboardRouteRouteChildren {
dashboardRepositoriesIndexRoute: typeof dashboardRepositoriesIndexRoute
dashboardSettingsIndexRoute: typeof dashboardSettingsIndexRoute
dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute
dashboardRepositoriesRepoIdSnapshotIdRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRoute
dashboardRepositoriesRepositoryIdEditRoute: typeof dashboardRepositoriesRepositoryIdEditRoute
dashboardBackupsBackupIdIndexRoute: typeof dashboardBackupsBackupIdIndexRoute
dashboardRepositoriesRepositoryIdIndexRoute: typeof dashboardRepositoriesRepositoryIdIndexRoute
dashboardBackupsBackupIdSnapshotIdRestoreRoute: typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
dashboardRepositoriesRepoIdSnapIdRestoreRoute: typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute: typeof dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute
dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute: typeof dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute
}
const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
dashboardBackupsScheduleIdRoute: dashboardBackupsScheduleIdRoute,
dashboardBackupsCreateRoute: dashboardBackupsCreateRoute,
dashboardNotificationsNotificationIdRoute:
dashboardNotificationsNotificationIdRoute,
dashboardNotificationsCreateRoute: dashboardNotificationsCreateRoute,
dashboardRepositoriesRepositoryIdRoute:
dashboardRepositoriesRepositoryIdRoute,
dashboardRepositoriesCreateRoute: dashboardRepositoriesCreateRoute,
dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute,
dashboardVolumesCreateRoute: dashboardVolumesCreateRoute,
@ -500,12 +518,17 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
dashboardRepositoriesIndexRoute: dashboardRepositoriesIndexRoute,
dashboardSettingsIndexRoute: dashboardSettingsIndexRoute,
dashboardVolumesIndexRoute: dashboardVolumesIndexRoute,
dashboardRepositoriesRepoIdSnapshotIdRoute:
dashboardRepositoriesRepoIdSnapshotIdRoute,
dashboardRepositoriesRepositoryIdEditRoute:
dashboardRepositoriesRepositoryIdEditRoute,
dashboardBackupsBackupIdIndexRoute: dashboardBackupsBackupIdIndexRoute,
dashboardRepositoriesRepositoryIdIndexRoute:
dashboardRepositoriesRepositoryIdIndexRoute,
dashboardBackupsBackupIdSnapshotIdRestoreRoute:
dashboardBackupsBackupIdSnapshotIdRestoreRoute,
dashboardRepositoriesRepoIdSnapIdRestoreRoute:
dashboardRepositoriesRepoIdSnapIdRestoreRoute,
dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute:
dashboardRepositoriesRepositoryIdSnapshotIdRestoreRoute,
dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute:
dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute,
}
const dashboardRouteRouteWithChildren = dashboardRouteRoute._addFileChildren(

View file

@ -17,7 +17,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
...getSnapshotDetailsOptions({ path: { id: schedule.data?.repositoryId, snapshotId: params.snapshotId } }),
}),
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: schedule.data?.repositoryId } }) }),
]);
])
return { snapshot, repository, schedule: schedule.data };
},
@ -51,5 +51,5 @@ function RouteComponent() {
snapshot={snapshot}
repository={repository}
/>
);
)
}

View file

@ -9,22 +9,22 @@ import {
} from "~/client/api-client/@tanstack/react-query.gen";
import { ScheduleDetailsPage } from "~/client/modules/backups/routes/backup-details";
export const Route = createFileRoute("/(dashboard)/backups/$scheduleId")({
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
component: RouteComponent,
loader: async ({ params, context }) => {
const { scheduleId } = params;
const { backupId } = params;
const [schedule, notifs, repos, scheduleNotifs, mirrors] = await Promise.all([
context.queryClient.ensureQueryData({ ...getBackupScheduleOptions({ path: { scheduleId } }) }),
context.queryClient.ensureQueryData({ ...getBackupScheduleOptions({ path: { scheduleId: backupId } }) }),
context.queryClient.ensureQueryData({ ...listNotificationDestinationsOptions() }),
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { scheduleId } }) }),
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { scheduleId } }) }),
]);
context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { scheduleId: backupId } }) }),
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { scheduleId: backupId } }) }),
])
void context.queryClient.prefetchQuery({
...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.shortId } }),
});
})
return { schedule, notifs, repos, scheduleNotifs, mirrors };
},
@ -47,7 +47,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$scheduleId")({
function RouteComponent() {
const loaderData = Route.useLoaderData();
const { scheduleId } = Route.useParams();
const { backupId } = Route.useParams();
return <ScheduleDetailsPage loaderData={loaderData} scheduleId={scheduleId} />;
return <ScheduleDetailsPage loaderData={loaderData} scheduleId={backupId} />;
}

View file

@ -6,32 +6,32 @@ import {
} from "~/client/api-client/@tanstack/react-query.gen";
import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details";
export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapshotId")({
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
const res = await context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { id: params.repoId } }),
});
...getRepositoryOptions({ path: { id: params.repositoryId } }),
})
void context.queryClient.prefetchQuery({
...getSnapshotDetailsOptions({
path: { id: params.repoId, snapshotId: params.snapshotId },
path: { id: params.repositoryId, snapshotId: params.snapshotId },
}),
});
})
void context.queryClient.prefetchQuery({
...listSnapshotFilesOptions({
path: { id: params.repoId, snapshotId: params.snapshotId },
path: { id: params.repositoryId, snapshotId: params.snapshotId },
query: { path: "/" },
}),
});
})
return res;
},
staticData: {
breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.name || "Repository", href: `/repositories/${match.params.repoId}` },
{ label: match.loaderData?.name || "Repository", href: `/repositories/${match.params.repositoryId}` },
{ label: match.params.snapshotId },
],
},
@ -47,7 +47,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapsho
});
function RouteComponent() {
const { repoId, snapshotId } = Route.useParams();
const { repositoryId, snapshotId } = Route.useParams();
return <SnapshotDetailsPage repositoryId={repoId} snapshotId={snapshotId} />;
return <SnapshotDetailsPage repositoryId={repositoryId} snapshotId={snapshotId} />;
}

View file

@ -2,30 +2,30 @@ import { createFileRoute } from "@tanstack/react-router";
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapId/restore")({
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
const [snapshot, repository] = await Promise.all([
context.queryClient.ensureQueryData({
...getSnapshotDetailsOptions({ path: { id: params.repoId, snapshotId: params.snapId } }),
...getSnapshotDetailsOptions({ path: { id: params.repositoryId, snapshotId: params.snapshotId } }),
}),
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repoId } }) }),
]);
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repositoryId } }) }),
])
return { snapshot, repository };
},
staticData: {
breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repoId}` },
{ label: match.params.snapId, href: `/repositories/${match.params.repoId}/${match.params.snapId}` },
{ label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repositoryId}` },
{ label: match.params.snapshotId, href: `/repositories/${match.params.repositoryId}/${match.params.snapshotId}` },
{ label: "Restore" },
],
},
head: ({ params }) => ({
meta: [
{ title: `Zerobyte - Restore Snapshot ${params.snapId}` },
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Restore files from a backup snapshot.",
@ -35,15 +35,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapId/
});
function RouteComponent() {
const { repoId, snapId } = Route.useParams();
const { repositoryId, snapshotId } = Route.useParams();
const { snapshot, repository } = Route.useLoaderData();
return (
<RestoreSnapshotPage
returnPath={`/repositories/${repoId}/${snapId}`}
returnPath={`/repositories/${repositoryId}/${snapshotId}`}
snapshot={snapshot}
repository={repository}
snapshotId={snapId}
snapshotId={snapshotId}
/>
);
)
}

View file

@ -0,0 +1,37 @@
import { createFileRoute } from "@tanstack/react-router";
import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { EditRepositoryPage } from "~/client/modules/repositories/routes/edit-repository";
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/edit")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
const repository = await context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { id: params.repositoryId } }),
})
return repository;
},
staticData: {
breadcrumb: (match) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.name || "Repository", href: `/repositories/${match.params.repositoryId}` },
{ label: "Edit" },
],
},
head: ({ loaderData }) => ({
meta: [
{ title: `Zerobyte - Edit ${loaderData?.name ?? "Repository"}` },
{
name: "description",
content: "Edit repository configuration.",
},
],
}),
});
function RouteComponent() {
const { repositoryId } = Route.useParams();
return <EditRepositoryPage repositoryId={repositoryId} />;
}

View file

@ -7,20 +7,20 @@ import {
} from "~/client/api-client/@tanstack/react-query.gen";
import RepositoryDetailsPage from "~/client/modules/repositories/routes/repository-details";
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId")({
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
void context.queryClient.prefetchQuery({
...listSnapshotsOptions({ path: { id: params.repositoryId } }),
});
})
void context.queryClient.prefetchQuery({
...listBackupSchedulesOptions(),
});
})
const res = await context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { id: params.repositoryId } }),
});
})
return res;
},

View file

@ -58,8 +58,7 @@ export const r2RepositoryConfigSchema = type({
export const localRepositoryConfigSchema = type({
backend: "'local'",
name: "string",
path: "string?",
path: "string",
}).and(baseRepositoryConfigSchema);
export const gcsRepositoryConfigSchema = type({

View file

@ -11,3 +11,5 @@ export const RESTIC_PASS_FILE = process.env.RESTIC_PASS_FILE || "/var/lib/zeroby
export const RCLONE_CONFIG_DIR = process.env.RCLONE_CONFIG_DIR || "/root/.config/rclone";
export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];
export const REGISTRATION_ENABLED_KEY = "registrations_enabled";

View file

@ -2,7 +2,7 @@ import { db } from "~/server/db/db";
import type { AuthMiddlewareContext } from "../auth";
import { logger } from "~/server/utils/logger";
import { ForbiddenError } from "http-errors-enhanced";
import { REGISTRATION_ENABLED_KEY } from "~/client/lib/constants";
import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
const { path } = ctx;

View file

@ -0,0 +1,19 @@
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { UnauthorizedError } from "http-errors-enhanced";
import { auth } from "~/server/lib/auth";
import { REGISTRATION_ENABLED_KEY, REPOSITORY_BASE } from "~/server/core/constants";
export const getServerConstants = createServerFn({ method: "GET" }).handler(async () => {
const headers = getRequestHeaders();
const session = await auth.api.getSession({ headers });
if (!session?.user) {
throw new UnauthorizedError("Invalid or expired session");
}
return {
REPOSITORY_BASE,
REGISTRATION_ENABLED_KEY,
};
});

View file

@ -2,6 +2,7 @@ import { logger } from "../../utils/logger";
import { v00001 } from "./migrations/00001-retag-snapshots";
import { v00002 } from "./migrations/00002-isolate-restic-passwords";
import { v00003 } from "./migrations/00003-assign-organization";
import { v00004 } from "./migrations/00004-concat-path-name";
import { sql } from "drizzle-orm";
import { appMetadataTable, usersTable } from "../../db/schema";
import { db } from "../../db/db";
@ -36,7 +37,7 @@ type MigrationEntity = {
dependsOn?: string[];
};
const registry: MigrationEntity[] = [v00001, v00002, v00003];
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004];
export const runMigrations = async () => {
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);

View file

@ -0,0 +1,96 @@
import { and, eq } from "drizzle-orm";
import { db } from "../../../db/db";
import { repositoriesTable } from "../../../db/schema";
import { logger } from "../../../utils/logger";
import { toMessage } from "~/server/utils/errors";
import { REPOSITORY_BASE } from "~/server/core/constants";
type MigrationError = { name: string; error: string };
const asString = (value: unknown): string | null => {
if (typeof value !== "string") {
return null;
}
return value;
};
const hasValue = (value: string | null): value is string => {
return value !== null && value.trim() !== "";
};
const trimTrailingSlashes = (value: string): string => {
return value.replace(/\/+$/, "");
};
const isPathAlreadyMigrated = (path: string, name: string): boolean => {
const normalizedPath = trimTrailingSlashes(path);
return normalizedPath.endsWith(`/${name}`);
};
const buildPath = (path: string | null, name: string): string => {
if (path === null || path.trim() === "") {
return `${REPOSITORY_BASE}/${name}`;
}
return `${trimTrailingSlashes(path)}/${name}`;
};
const execute = async () => {
const errors: MigrationError[] = [];
const localRepositories = await db.query.repositoriesTable.findMany({ where: { type: "local" } });
let migratedCount = 0;
for (const repository of localRepositories) {
try {
const configValue = repository.config as unknown;
if (typeof configValue !== "object" || configValue === null || Array.isArray(configValue)) {
errors.push({
name: `repository:${repository.id}`,
error: "Repository config is not a valid JSON object",
});
continue;
}
const config = { ...(configValue as Record<string, unknown>) };
const localRepositoryName = asString(config.name);
if (!hasValue(localRepositoryName)) {
continue;
}
const currentPath = asString(config.path);
if (hasValue(currentPath) && isPathAlreadyMigrated(currentPath, localRepositoryName)) {
continue;
}
config.path = buildPath(currentPath, localRepositoryName);
await db
.update(repositoriesTable)
.set({
config: config as typeof repository.config,
updatedAt: Date.now(),
})
.where(and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.type, "local")));
migratedCount += 1;
} catch (err) {
errors.push({
name: `repository:${repository.id}`,
error: toMessage(err),
});
}
}
logger.info(`Migration 00004-concat-path-name updated ${migratedCount} local repositories.`);
return { success: errors.length === 0, errors };
};
export const v00004 = {
execute,
id: "00004-concat-path-name",
type: "maintenance" as const,
};

View file

@ -1,9 +1,44 @@
import { test, describe, expect } from "bun:test";
import crypto from "node:crypto";
import { createApp } from "~/server/app";
import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema";
import { generateShortId } from "~/server/utils/id";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
import type { RepositoryConfig } from "~/schemas/restic";
const app = createApp();
const createRepositoryRecord = async (organizationId: string) => {
const [repository] = await db
.insert(repositoriesTable)
.values({
id: crypto.randomUUID(),
shortId: generateShortId(),
name: `Repository-${crypto.randomUUID()}`,
type: "local",
config: {
backend: "local",
name: generateShortId(),
path: `/tmp/repository-${crypto.randomUUID()}`,
isExistingRepository: true,
},
compressionMode: "off",
status: "error",
lastChecked: Date.now(),
lastError: "old error",
doctorResult: {
success: false,
steps: [],
completedAt: Date.now(),
},
organizationId,
})
.returning();
return repository;
};
describe("repositories security", () => {
test("should return 401 if no session cookie is provided", async () => {
const res = await app.request("/api/v1/repositories");
@ -96,3 +131,100 @@ describe("repositories security", () => {
});
});
});
describe("repositories updates", () => {
test("PATCH updates full config and metadata using shortId", async () => {
const { token, organizationId } = await createTestSession();
const repository = await createRepositoryRecord(organizationId);
const nextPath = `/tmp/updated-${crypto.randomUUID()}`;
const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
method: "PATCH",
headers: {
...getAuthHeaders(token),
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Updated repository",
compressionMode: "max",
config: {
backend: "local",
path: nextPath,
isExistingRepository: true,
},
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.name).toBe("Updated repository");
expect(body.compressionMode).toBe("max");
expect(body.config.backend).toBe("local");
expect(body.config.path).toBe(nextPath);
expect(body.status).toBe("unknown");
expect(body.lastChecked).toBeNull();
expect(body.lastError).toBeNull();
expect(body.doctorResult).toBeNull();
const updated = await db.query.repositoriesTable.findFirst({
where: { id: repository.id },
});
const config = updated?.config as Extract<RepositoryConfig, { backend: "local" }>;
expect(updated).toBeTruthy();
expect(updated?.name).toBe("Updated repository");
expect(updated?.compressionMode).toBe("max");
expect(config.path).toBe(nextPath);
expect(updated?.status).toBe("unknown");
expect(updated?.lastChecked).toBeNull();
expect(updated?.lastError).toBeNull();
expect(updated?.doctorResult).toBeNull();
});
test("PATCH rejects backend changes", async () => {
const { token, organizationId } = await createTestSession();
const repository = await createRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.id}`, {
method: "PATCH",
headers: {
...getAuthHeaders(token),
"Content-Type": "application/json",
},
body: JSON.stringify({
config: {
backend: "s3",
endpoint: "s3.amazonaws.com",
bucket: "bucket-name",
accessKeyId: "access-key",
secretAccessKey: "secret-key",
},
}),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.message).toBe("Repository backend cannot be changed");
});
test("PATCH rejects invalid config payload", async () => {
const { token, organizationId } = await createTestSession();
const repository = await createRepositoryRecord(organizationId);
const res = await app.request(`/api/v1/repositories/${repository.id}`, {
method: "PATCH",
headers: {
...getAuthHeaders(token),
"Content-Type": "application/json",
},
body: JSON.stringify({
config: {
backend: "local",
},
}),
});
expect(res.status).toBe(400);
});
});

View file

@ -140,6 +140,7 @@ export const deleteRepositoryDto = describeRoute({
export const updateRepositoryBody = type({
name: "string?",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
config: repositoryConfigSchema.optional(),
});
export type UpdateRepositoryBody = typeof updateRepositoryBody.infer;
@ -160,6 +161,9 @@ export const updateRepositoryDto = describeRoute({
},
},
},
400: {
description: "Invalid repository update payload",
},
404: {
description: "Repository not found",
},

View file

@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
@ -23,6 +23,7 @@ import { executeDoctor } from "./doctor";
import { logger } from "~/server/utils/logger";
import { parseRetentionCategories, type RetentionCategory } from "~/server/utils/retention-categories";
import { backupsService } from "../backups/backups.service";
import type { UpdateRepositoryBody } from "./repositories.dto";
const runningDoctors = new Map<string, AbortController>();
@ -80,16 +81,51 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
return encryptedConfig as RepositoryConfig;
};
const decryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
const decryptedConfig: Record<string, unknown> = { ...config };
if (config.customPassword) {
decryptedConfig.customPassword = await cryptoUtils.resolveSecret(config.customPassword);
}
if (config.cacert) {
decryptedConfig.cacert = await cryptoUtils.resolveSecret(config.cacert);
}
switch (config.backend) {
case "s3":
case "r2":
decryptedConfig.accessKeyId = await cryptoUtils.resolveSecret(config.accessKeyId);
decryptedConfig.secretAccessKey = await cryptoUtils.resolveSecret(config.secretAccessKey);
break;
case "gcs":
decryptedConfig.credentialsJson = await cryptoUtils.resolveSecret(config.credentialsJson);
break;
case "azure":
decryptedConfig.accountKey = await cryptoUtils.resolveSecret(config.accountKey);
break;
case "rest":
if (config.username) {
decryptedConfig.username = await cryptoUtils.resolveSecret(config.username);
}
if (config.password) {
decryptedConfig.password = await cryptoUtils.resolveSecret(config.password);
}
break;
case "sftp":
decryptedConfig.privateKey = await cryptoUtils.resolveSecret(config.privateKey);
break;
}
return decryptedConfig as RepositoryConfig;
};
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
const organizationId = getOrganizationId();
const id = crypto.randomUUID();
const shortId = generateShortId();
let processedConfig = config;
if (config.backend === "local" && !config.isExistingRepository) {
processedConfig = { ...config, name: shortId };
}
const encryptedConfig = await encryptConfig(processedConfig);
const [created] = await db
@ -526,40 +562,76 @@ const refreshSnapshots = async (id: string) => {
}
};
const updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
const updateRepository = async (id: string, updates: UpdateRepositoryBody) => {
const existing = await findRepository(id);
if (!existing) {
throw new NotFoundError("Repository not found");
}
const newConfig = repositoryConfigSchema(existing.config);
if (newConfig instanceof type.errors) {
const existingConfig = repositoryConfigSchema(existing.config);
if (existingConfig instanceof type.errors) {
throw new InternalServerError("Invalid repository configuration");
}
const encryptedConfig = await encryptConfig(newConfig);
let newName = existing.name;
if (updates.name !== undefined && updates.name !== existing.name) {
if (updates.name) {
newName = updates.name.trim();
if (newName.length === 0) {
throw new BadRequestError("Repository name cannot be empty");
}
}
let parsedConfig = existingConfig;
if (updates.config) {
const nextConfig = repositoryConfigSchema(updates.config);
if (nextConfig instanceof type.errors) {
throw new BadRequestError("Invalid repository configuration");
}
if (nextConfig.backend !== existing.type) {
throw new BadRequestError("Repository backend cannot be changed");
}
parsedConfig = nextConfig;
}
const decryptedExisting = existingConfig ? await decryptConfig(existingConfig) : null;
const configChanged =
updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig);
const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig;
const updatedAt = Date.now();
const updatePayload = {
name: newName,
compressionMode: updates.compressionMode ?? existing.compressionMode,
updatedAt,
config: encryptedConfig,
...(configChanged
? {
status: "unknown" as const,
lastChecked: null,
lastError: null,
doctorResult: null,
}
: {}),
};
const [updated] = await db
.update(repositoriesTable)
.set({
name: newName,
compressionMode: updates.compressionMode ?? existing.compressionMode,
updatedAt: Date.now(),
config: encryptedConfig,
})
.where(eq(repositoriesTable.id, existing.id))
.set(updatePayload)
.where(and(eq(repositoriesTable.id, existing.id), eq(repositoriesTable.organizationId, existing.organizationId)))
.returning();
if (!updated) {
throw new InternalServerError("Failed to update repository");
}
if (configChanged) {
cache.delByPrefix(`snapshots:${existing.id}:`);
cache.delByPrefix(`ls:${existing.id}:`);
}
return { repository: updated };
};

View file

@ -6,7 +6,7 @@ import { cache } from "../../utils/cache";
import { logger } from "~/server/utils/logger";
import { db } from "../../db/db";
import { appMetadataTable } from "../../db/schema";
import { REGISTRATION_ENABLED_KEY } from "~/client/lib/constants";
import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
const CACHE_TTL = 60 * 60;

View file

@ -4,7 +4,7 @@ import path from "node:path";
import os from "node:os";
import { throttle } from "es-toolkit";
import { type } from "arktype";
import { REPOSITORY_BASE, RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants";
import { RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants";
import { config as appConfig } from "../core/config";
import { logger } from "./logger";
import { cryptoUtils } from "./crypto";
@ -42,12 +42,7 @@ const snapshotInfoSchema = type({
export const buildRepoUrl = (config: RepositoryConfig): string => {
switch (config.backend) {
case "local":
if (config.isExistingRepository) {
if (!config.path) throw new Error("Path is required for existing local repositories");
return config.path;
}
return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`;
return config.path;
case "s3":
return `s3:${config.endpoint}/${config.bucket}`;
case "r2": {

View file

@ -11,7 +11,7 @@ export const createTestRepository = async (overrides: Partial<RepositoryInsert>
name: faker.string.alphanumeric(10),
shortId: faker.string.alphanumeric(6),
config: {
name: "test-repo",
path: `/var/lib/zerobyte/repositories/${faker.string.alphanumeric(8)}`,
backend: "local",
},
type: "local",