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:
parent
60c8bd77f5
commit
1017f1a38b
31 changed files with 1037 additions and 374 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -37,3 +37,4 @@ playwright/temp
|
|||
# OpenAPI error logs
|
||||
openapi-ts-error-*.log
|
||||
.output
|
||||
tmp/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
|
||||
export const REGISTRATION_ENABLED_KEY = "registrations_enabled";
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
176
app/client/modules/repositories/routes/edit-repository.tsx
Normal file
176
app/client/modules/repositories/routes/edit-repository.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
37
app/routes/(dashboard)/repositories/$repositoryId/edit.tsx
Normal file
37
app/routes/(dashboard)/repositories/$repositoryId/edit.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
19
app/server/lib/functions/server-constants.ts
Normal file
19
app/server/lib/functions/server-constants.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue