fix: allow arbitrary name for repositories and correctly import existing local repos (#197)

* fix: allow arbitrary name for repositories and correctly import existing local repos

* chore: rebase conflicts
This commit is contained in:
Nico
2025-12-22 20:50:53 +01:00
committed by GitHub
parent c6b569bed6
commit ac9de54a7b
29 changed files with 1029 additions and 212 deletions

View File

@@ -407,7 +407,7 @@ export const deleteRepositoryMutation = (options?: Partial<Options<DeleteReposit
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey('getRepository', options);
/**
* Get a single repository by name
* Get a single repository by ID
*/
export const getRepositoryOptions = (options: Options<GetRepositoryData>) => queryOptions<GetRepositoryResponse, DefaultError, GetRepositoryResponse, ReturnType<typeof getRepositoryQueryKey>>({
queryFn: async ({ queryKey, signal }) => {

View File

@@ -170,18 +170,18 @@ export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?
/**
* Delete a repository
*/
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}', ...options });
/**
* Get a single repository by name
* Get a single repository by ID
*/
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}', ...options });
/**
* Update a repository's name or settings
*/
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
url: '/api/v1/repositories/{name}',
url: '/api/v1/repositories/{id}',
...options,
headers: {
'Content-Type': 'application/json',
@@ -192,28 +192,28 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
/**
* List all snapshots in a repository
*/
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots', ...options });
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/snapshots', ...options });
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}', ...options });
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}', ...options });
/**
* List files and directories in a snapshot
*/
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', ...options });
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}/files', ...options });
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/restore',
url: '/api/v1/repositories/{id}/restore',
...options,
headers: {
'Content-Type': 'application/json',
@@ -224,7 +224,7 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: O
/**
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
*/
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/doctor', ...options });
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/doctor', ...options });
/**
* List all backup schedules

View File

@@ -870,6 +870,7 @@ export type CreateRepositoryResponses = {
repository: {
id: string;
name: string;
shortId: string;
};
};
};
@@ -898,10 +899,10 @@ export type ListRcloneRemotesResponse = ListRcloneRemotesResponses[keyof ListRcl
export type DeleteRepositoryData = {
body?: never;
path: {
name: string;
id: string;
};
query?: never;
url: '/api/v1/repositories/{name}';
url: '/api/v1/repositories/{id}';
};
export type DeleteRepositoryResponses = {
@@ -918,10 +919,10 @@ export type DeleteRepositoryResponse = DeleteRepositoryResponses[keyof DeleteRep
export type GetRepositoryData = {
body?: never;
path: {
name: string;
id: string;
};
query?: never;
url: '/api/v1/repositories/{name}';
url: '/api/v1/repositories/{id}';
};
export type GetRepositoryResponses = {
@@ -1011,10 +1012,10 @@ export type UpdateRepositoryData = {
name?: string;
};
path: {
name: string;
id: string;
};
query?: never;
url: '/api/v1/repositories/{name}';
url: '/api/v1/repositories/{id}';
};
export type UpdateRepositoryErrors = {
@@ -1112,12 +1113,12 @@ export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRep
export type ListSnapshotsData = {
body?: never;
path: {
name: string;
id: string;
};
query?: {
backupId?: string;
};
url: '/api/v1/repositories/{name}/snapshots';
url: '/api/v1/repositories/{id}/snapshots';
};
export type ListSnapshotsResponses = {
@@ -1139,11 +1140,11 @@ export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsRe
export type DeleteSnapshotData = {
body?: never;
path: {
name: string;
id: string;
snapshotId: string;
};
query?: never;
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}';
url: '/api/v1/repositories/{id}/snapshots/{snapshotId}';
};
export type DeleteSnapshotResponses = {
@@ -1160,11 +1161,11 @@ export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapsho
export type GetSnapshotDetailsData = {
body?: never;
path: {
name: string;
id: string;
snapshotId: string;
};
query?: never;
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}';
url: '/api/v1/repositories/{id}/snapshots/{snapshotId}';
};
export type GetSnapshotDetailsResponses = {
@@ -1186,13 +1187,13 @@ export type GetSnapshotDetailsResponse = GetSnapshotDetailsResponses[keyof GetSn
export type ListSnapshotFilesData = {
body?: never;
path: {
name: string;
id: string;
snapshotId: string;
};
query?: {
path?: string;
};
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files';
url: '/api/v1/repositories/{id}/snapshots/{snapshotId}/files';
};
export type ListSnapshotFilesResponses = {
@@ -1235,10 +1236,10 @@ export type RestoreSnapshotData = {
targetPath?: string;
};
path: {
name: string;
id: string;
};
query?: never;
url: '/api/v1/repositories/{name}/restore';
url: '/api/v1/repositories/{id}/restore';
};
export type RestoreSnapshotResponses = {
@@ -1258,10 +1259,10 @@ export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnap
export type DoctorRepositoryData = {
body?: never;
path: {
name: string;
id: string;
};
query?: never;
url: '/api/v1/repositories/{name}/doctor';
url: '/api/v1/repositories/{id}/doctor';
};
export type DoctorRepositoryResponses = {

View File

@@ -14,18 +14,18 @@ import { FileTree } from "~/client/components/file-tree";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import type { Snapshot } from "~/client/lib/types";
import type { Repository, Snapshot } from "~/client/lib/types";
type RestoreLocation = "original" | "custom";
interface RestoreFormProps {
snapshot: Snapshot;
repositoryName: string;
repository: Repository;
snapshotId: string;
returnPath: string;
}
export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) {
export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -42,10 +42,9 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
path: { id: repository.id, snapshotId },
query: { path: volumeBasePath },
}),
enabled: !!repositoryName && !!snapshotId,
});
const stripBasePath = useCallback(
@@ -78,7 +77,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
path: { id: repository.id, snapshotId },
query: { path },
}),
);
@@ -86,7 +85,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
path: { id: repository.id, snapshotId },
query: { path },
}),
);
@@ -111,8 +110,6 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
});
const handleRestore = useCallback(() => {
if (!repositoryName || !snapshotId) return;
const excludeXattrArray = excludeXattr
?.split(",")
.map((s) => s.trim())
@@ -125,7 +122,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
const includePaths = pathsArray.map((path) => addBasePath(path));
restoreSnapshot({
path: { name: repositoryName },
path: { id: repository.id },
body: {
snapshotId,
include: includePaths.length > 0 ? includePaths : undefined,
@@ -136,7 +133,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
},
});
}, [
repositoryName,
repository.id,
snapshotId,
excludeXattr,
restoreLocation,
@@ -156,7 +153,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
<div>
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
<p className="text-sm text-muted-foreground">
{repositoryName} / {snapshotId}
{repository.name} / {snapshotId}
</p>
</div>
<div className="flex gap-2">

View File

@@ -25,10 +25,10 @@ import type { BackupSchedule, Snapshot } from "../lib/types";
type Props = {
snapshots: Snapshot[];
backups: BackupSchedule[];
repositoryName: string;
repositoryId: string;
};
export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -53,7 +53,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) =>
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: repositoryName, snapshotId: snapshotToDelete },
path: { id: repositoryId, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
@@ -65,7 +65,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) =>
};
const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`);
navigate(`/repositories/${repositoryId}/${snapshotId}`);
};
return (

View File

@@ -285,7 +285,7 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
<TableCell>
<div className="flex items-center gap-2">
<Link
to={`/repositories/${repository.name}`}
to={`/repositories/${repository.shortId}`}
className="hover:underline flex items-center gap-2"
>
<RepositoryIcon backend={repository.type} className="h-4 w-4" />

View File

@@ -90,7 +90,7 @@ export const ScheduleSummary = (props: Props) => {
<span>{schedule.volume.name}</span>
</Link>
<span className="mx-2"></span>
<Link to={`/repositories/${schedule.repository.name}`} className="hover:underline">
<Link to={`/repositories/${schedule.repository.shortId}`} className="hover:underline">
<Database className="inline h-4 w-4 mr-2 text-strong-accent" />
<span className="text-strong-accent">{schedule.repository.name}</span>
</Link>

View File

@@ -11,14 +11,14 @@ import { useFileBrowser } from "~/client/hooks/use-file-browser";
interface Props {
snapshot: Snapshot;
repositoryName: string;
repositoryId: string;
backupId?: string;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
const { snapshot, repositoryId, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
const queryClient = useQueryClient();
@@ -26,7 +26,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
path: { id: repositoryId, snapshotId: snapshot.short_id },
query: { path: volumeBasePath },
}),
});
@@ -61,7 +61,7 @@ export const SnapshotFileBrowser = (props: Props) => {
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
path: { id: repositoryId, snapshotId: snapshot.short_id },
query: { path },
}),
);
@@ -69,7 +69,7 @@ export const SnapshotFileBrowser = (props: Props) => {
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
path: { id: repositoryId, snapshotId: snapshot.short_id },
query: { path },
}),
);
@@ -82,7 +82,7 @@ export const SnapshotFileBrowser = (props: Props) => {
return (
<div className="space-y-4">
<Card className="h-[600px] flex flex-col">
<Card className="h-150 flex flex-col">
<CardHeader>
<div className="flex items-start justify-between">
<div>
@@ -94,7 +94,7 @@ export const SnapshotFileBrowser = (props: Props) => {
to={
backupId
? `/backups/${backupId}/${snapshot.short_id}/restore`
: `/repositories/${repositoryName}/${snapshot.short_id}/restore`
: `/repositories/${repositoryId}/${snapshot.short_id}/restore`
}
className={buttonVariants({ variant: "primary", size: "sm" })}
>

View File

@@ -80,7 +80,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
isLoading,
failureReason,
} = useQuery({
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.id.toString() } }),
});
const updateSchedule = useMutation({
@@ -198,7 +198,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: schedule.repository.name, snapshotId: snapshotToDelete },
path: { id: schedule.repository.shortId, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
@@ -260,7 +260,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
<SnapshotFileBrowser
key={selectedSnapshot?.short_id}
snapshot={selectedSnapshot}
repositoryName={schedule.repository.name}
repositoryId={schedule.repository.shortId}
backupId={schedule.id.toString()}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}

View File

@@ -1,5 +1,5 @@
import { redirect } from "react-router";
import { getBackupSchedule, getSnapshotDetails } from "~/client/api-client";
import { getBackupSchedule, getRepository, getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
@@ -26,27 +26,30 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
if (!schedule.data) return redirect("/backups");
const repositoryName = schedule.data.repository.name;
const repositoryId = schedule.data.repository.id;
const snapshot = await getSnapshotDetails({
path: { name: repositoryName, snapshotId: params.snapshotId },
path: { id: repositoryId, snapshotId: params.snapshotId },
});
if (!snapshot.data) return redirect(`/backups/${params.id}`);
const repository = await getRepository({ path: { id: repositoryId } });
if (!repository.data) return redirect(`/backups/${params.id}`);
return {
snapshot: snapshot.data,
repositoryName,
repository: repository.data,
snapshotId: params.snapshotId,
backupId: params.id,
};
};
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
const { snapshot, repositoryName, snapshotId, backupId } = loaderData;
const { snapshot, repository, snapshotId, backupId } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repositoryName={repositoryName}
repository={repository}
snapshotId={snapshotId}
returnPath={`/backups/${backupId}`}
/>

View File

@@ -3,7 +3,7 @@ import { type } from "arktype";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Save } from "lucide-react";
import { cn, slugify } from "~/client/lib/utils";
import { cn } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "../../../components/ui/button";
import {
@@ -109,7 +109,7 @@ export const CreateRepositoryForm = ({
<Input
{...field}
placeholder="Repository name"
onChange={(e) => field.onChange(slugify(e.target.value))}
onChange={(e) => field.onChange(e.target.value)}
maxLength={32}
minLength={2}
/>

View File

@@ -36,7 +36,7 @@ export default function CreateRepository() {
...createRepositoryMutation(),
onSuccess: (data) => {
toast.success("Repository created successfully");
navigate(`/repositories/${data.repository.name}`);
navigate(`/repositories/${data.repository.shortId}`);
},
});

View File

@@ -148,9 +148,9 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
) : (
filteredRepositories.map((repository) => (
<TableRow
key={repository.name}
key={repository.id}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/repositories/${repository.name}`)}
onClick={() => navigate(`/repositories/${repository.shortId}`)}
>
<TableCell className="font-medium text-strong-accent">{repository.name}</TableCell>
<TableCell>

View File

@@ -30,13 +30,13 @@ import { Loader2, Stethoscope, Trash2, X } from "lucide-react";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name },
{ label: match.loaderData?.name || match.params.id },
],
};
export function meta({ params }: Route.MetaArgs) {
export function meta({ params, loaderData }: Route.MetaArgs) {
return [
{ title: `Zerobyte - ${params.name}` },
{ title: `Zerobyte - ${loaderData?.name || params.id}` },
{
name: "description",
content: "View repository configuration, status, and snapshots.",
@@ -45,7 +45,7 @@ export function meta({ params }: Route.MetaArgs) {
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const repository = await getRepository({ path: { name: params.name ?? "" } });
const repository = await getRepository({ path: { id: params.id ?? "" } });
if (repository.data) return repository.data;
return redirect("/repositories");
@@ -62,13 +62,13 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
const activeTab = searchParams.get("tab") || "info";
const { data } = useQuery({
...getRepositoryOptions({ path: { name: loaderData.name } }),
...getRepositoryOptions({ path: { id: loaderData.id } }),
initialData: loaderData,
});
useEffect(() => {
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
}, [queryClient, data.name]);
queryClient.prefetchQuery(listSnapshotsOptions({ path: { id: data.id } }));
}, [queryClient, data.id]);
const deleteRepo = useMutation({
...deleteRepositoryMutation(),
@@ -108,7 +108,7 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteRepo.mutate({ path: { name: data.name } });
deleteRepo.mutate({ path: { id: data.id } });
};
const getStepLabel = (step: string) => {
@@ -142,7 +142,7 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
</div>
<div className="flex gap-4">
<Button
onClick={() => doctorMutation.mutate({ path: { name: data.name } })}
onClick={() => doctorMutation.mutate({ path: { id: data.id } })}
disabled={doctorMutation.isPending}
variant={"outline"}
>

View File

@@ -1,13 +1,13 @@
import { redirect } from "react-router";
import { getSnapshotDetails } from "~/client/api-client";
import { getRepository, getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name, href: `/repositories/${match.params.name}` },
{ label: match.params.snapshotId, href: `/repositories/${match.params.name}/${match.params.snapshotId}` },
{ label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
{ label: match.params.snapshotId, href: `/repositories/${match.params.id}/${match.params.snapshotId}` },
{ label: "Restore" },
],
};
@@ -24,22 +24,25 @@ export function meta({ params }: Route.MetaArgs) {
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({
path: { name: params.name, snapshotId: params.snapshotId },
path: { id: params.id, snapshotId: params.snapshotId },
});
if (snapshot.data) return { snapshot: snapshot.data, name: params.name, snapshotId: params.snapshotId };
if (!snapshot.data) return redirect("/repositories");
return redirect("/repositories");
const repository = await getRepository({ path: { id: params.id } });
if (!repository.data) return redirect(`/repositories`);
return { snapshot: snapshot.data, id: params.id, repository: repository.data, snapshotId: params.snapshotId };
};
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
const { snapshot, name, snapshotId } = loaderData;
const { snapshot, id, snapshotId, repository } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repositoryName={name}
repository={repository}
snapshotId={snapshotId}
returnPath={`/repositories/${name}/${snapshotId}`}
returnPath={`/repositories/${id}/${snapshotId}`}
/>
);
}

View File

@@ -3,13 +3,13 @@ import { redirect, useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { getSnapshotDetails } from "~/client/api-client";
import { getRepository, getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name, href: `/repositories/${match.params.name}` },
{ label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
{ label: match.params.snapshotId },
],
};
@@ -26,28 +26,31 @@ export function meta({ params }: Route.MetaArgs) {
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({
path: { name: params.name, snapshotId: params.snapshotId },
path: { id: params.id, snapshotId: params.snapshotId },
});
if (snapshot.data) return snapshot.data;
if (!snapshot.data) return redirect("/repositories");
return redirect("/repositories");
const repository = await getRepository({ path: { id: params.id } });
if (!repository.data) return redirect("/repositories");
return { snapshot: snapshot.data, repository: repository.data };
};
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
const { name, snapshotId } = useParams<{
name: string;
const { id, snapshotId } = useParams<{
id: string;
snapshotId: string;
}>();
const { data } = useQuery({
...listSnapshotFilesOptions({
path: { name: name ?? "", snapshotId: snapshotId ?? "" },
path: { id: id ?? "", snapshotId: snapshotId ?? "" },
query: { path: "/" },
}),
enabled: !!name && !!snapshotId,
enabled: !!id && !!snapshotId,
});
if (!name || !snapshotId) {
if (!id || !snapshotId) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Invalid snapshot reference</p>
@@ -59,12 +62,12 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{name}</h1>
<h1 className="text-2xl font-bold">{loaderData.repository.name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
</div>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
<SnapshotFileBrowser repositoryId={id} snapshot={loaderData.snapshot} />
{data?.snapshot && (
<Card>

View File

@@ -1,7 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { useNavigate } from "react-router";
import { Check, Save } from "lucide-react";
import { Card } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button";
@@ -19,9 +18,7 @@ import {
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import type { Repository } from "~/client/lib/types";
import { slugify } from "~/client/lib/utils";
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { UpdateRepositoryResponse } from "~/client/api-client/types.gen";
import type { CompressionMode } from "~/schemas/restic";
type Props = {
@@ -29,22 +26,19 @@ type Props = {
};
export const RepositoryInfoTabContent = ({ repository }: Props) => {
const navigate = useNavigate();
const [name, setName] = useState(repository.name);
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
(repository.compressionMode as CompressionMode) || "off",
);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const isImportedLocal = repository.type === "local" && repository.config.isExistingRepository;
const updateMutation = useMutation({
...updateRepositoryMutation(),
onSuccess: (data: UpdateRepositoryResponse) => {
onSuccess: () => {
toast.success("Repository updated successfully");
setShowConfirmDialog(false);
if (data.name !== repository.name) {
navigate(`/repositories/${data.name}`);
}
},
onError: (error) => {
toast.error("Failed to update repository", { description: error.message, richColors: true });
@@ -59,7 +53,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
const confirmUpdate = () => {
updateMutation.mutate({
path: { name: repository.name },
path: { id: repository.id },
body: { name, compressionMode },
});
};
@@ -79,12 +73,17 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
<Input
id="name"
value={name}
onChange={(e) => setName(slugify(e.target.value))}
onChange={(e) => setName(e.target.value)}
placeholder="Repository name"
maxLength={32}
minLength={2}
disabled={isImportedLocal}
/>
<p className="text-sm text-muted-foreground">Unique identifier for the repository.</p>
<p className="text-sm text-muted-foreground">
{isImportedLocal
? "Imported local repositories cannot be renamed."
: "Unique identifier for the repository."}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="compressionMode">Compression mode</Label>

View File

@@ -17,7 +17,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
const [searchQuery, setSearchQuery] = useState("");
const { data, isFetching, failureReason } = useQuery({
...listSnapshotsOptions({ path: { name: repository.name } }),
...listSnapshotsOptions({ path: { id: repository.id } }),
initialData: [],
});
@@ -137,7 +137,11 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
</TableBody>
</Table>
) : (
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} backups={schedules.data ?? []} />
<SnapshotsTable
snapshots={filteredSnapshots}
repositoryId={repository.shortId}
backups={schedules.data ?? []}
/>
)}
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span>

View File

@@ -0,0 +1 @@
DROP INDEX `repositories_table_name_unique`;

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS `repositories_table_name_unique`;

View File

@@ -0,0 +1,832 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ca46a423-51ca-45ae-9470-f82172a67bd3",
"prevId": "f19cb32f-2280-42dd-a86a-aba7c0409d9f",
"tables": {
"app_metadata": {
"name": "app_metadata",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_mirrors_table": {
"name": "backup_schedule_mirrors_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_copy_at": {
"name": "last_copy_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_status": {
"name": "last_copy_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_error": {
"name": "last_copy_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedule_mirrors_table_schedule_id_repository_id_unique": {
"name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique",
"columns": [
"schedule_id",
"repository_id"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_notifications_table": {
"name": "backup_schedule_notifications_table",
"columns": {
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"destination_id": {
"name": "destination_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notify_on_start": {
"name": "notify_on_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_success": {
"name": "notify_on_success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_warning": {
"name": "notify_on_warning",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"notify_on_failure": {
"name": "notify_on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "notification_destinations_table",
"columnsFrom": [
"destination_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
"columns": [
"schedule_id",
"destination_id"
],
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedules_table": {
"name": "backup_schedules_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"volume_id": {
"name": "volume_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retention_policy": {
"name": "retention_policy",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_patterns": {
"name": "exclude_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"exclude_if_present": {
"name": "exclude_if_present",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"include_patterns": {
"name": "include_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"last_backup_at": {
"name": "last_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_status": {
"name": "last_backup_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_error": {
"name": "last_backup_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"next_backup_at": {
"name": "next_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"one_file_system": {
"name": "one_file_system",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedules_table_name_unique": {
"name": "backup_schedules_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "volumes_table",
"columnsFrom": [
"volume_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_destinations_table": {
"name": "notification_destinations_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"notification_destinations_table_name_unique": {
"name": "notification_destinations_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repositories_table": {
"name": "repositories_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"repositories_table_short_id_unique": {
"name": "repositories_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"has_downloaded_restic_password": {
"name": "has_downloaded_restic_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_short_id_unique": {
"name": "volumes_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -176,6 +176,13 @@
"when": 1766325504548,
"tag": "0024_schedules-one-fs",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1766431021321,
"tag": "0025_remarkable_pete_wisdom",
"breakpoints": true
}
]
}

View File

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

View File

@@ -51,7 +51,7 @@ export type Session = typeof sessionsTable.$inferSelect;
export const repositoriesTable = sqliteTable("repositories_table", {
id: text().primaryKey(),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
name: text().notNull(),
type: text().$type<RepositoryBackend>().notNull(),
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),

View File

@@ -26,7 +26,7 @@ const ensureLatestConfigurationSchema = async () => {
const repositories = await db.query.repositoriesTable.findMany({});
for (const repo of repositories) {
await repositoriesService.updateRepository(repo.name, {}).catch((err) => {
await repositoriesService.updateRepository(repo.id, {}).catch((err) => {
logger.error(`Failed to update repository ${repo.name}: ${err}`);
});
}

View File

@@ -61,23 +61,23 @@ export const repositoriesController = new Hono()
return c.json(remotes);
})
.get("/:name", getRepositoryDto, async (c) => {
const { name } = c.req.param();
const res = await repositoriesService.getRepository(name);
.get("/:id", getRepositoryDto, async (c) => {
const { id } = c.req.param();
const res = await repositoriesService.getRepository(id);
return c.json<GetRepositoryDto>(res.repository, 200);
})
.delete("/:name", deleteRepositoryDto, async (c) => {
const { name } = c.req.param();
await repositoriesService.deleteRepository(name);
.delete("/:id", deleteRepositoryDto, async (c) => {
const { id } = c.req.param();
await repositoriesService.deleteRepository(id);
return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
})
.get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
const { name } = c.req.param();
.get("/:id/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
const { id } = c.req.param();
const { backupId } = c.req.valid("query");
const res = await repositoriesService.listSnapshots(name, backupId);
const res = await repositoriesService.listSnapshots(id, backupId);
const snapshots = res.map((snapshot) => {
const { summary } = snapshot;
@@ -100,9 +100,9 @@ export const repositoriesController = new Hono()
return c.json<ListSnapshotsDto>(snapshots, 200);
})
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
const { name, snapshotId } = c.req.param();
const snapshot = await repositoriesService.getSnapshotDetails(name, snapshotId);
.get("/:id/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
const { id, snapshotId } = c.req.param();
const snapshot = await repositoriesService.getSnapshotDetails(id, snapshotId);
let duration = 0;
if (snapshot.summary) {
@@ -123,48 +123,48 @@ export const repositoriesController = new Hono()
return c.json<GetSnapshotDetailsDto>(response, 200);
})
.get(
"/:name/snapshots/:snapshotId/files",
"/:id/snapshots/:snapshotId/files",
listSnapshotFilesDto,
validator("query", listSnapshotFilesQuery),
async (c) => {
const { name, snapshotId } = c.req.param();
const { id, snapshotId } = c.req.param();
const { path } = c.req.valid("query");
const decodedPath = path ? decodeURIComponent(path) : undefined;
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, decodedPath);
const result = await repositoriesService.listSnapshotFiles(id, snapshotId, decodedPath);
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
return c.json<ListSnapshotFilesDto>(result, 200);
},
)
.post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
const { name } = c.req.param();
.post("/:id/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
const { id } = c.req.param();
const { snapshotId, ...options } = c.req.valid("json");
const result = await repositoriesService.restoreSnapshot(name, snapshotId, options);
const result = await repositoriesService.restoreSnapshot(id, snapshotId, options);
return c.json<RestoreSnapshotDto>(result, 200);
})
.post("/:name/doctor", doctorRepositoryDto, async (c) => {
const { name } = c.req.param();
.post("/:id/doctor", doctorRepositoryDto, async (c) => {
const { id } = c.req.param();
const result = await repositoriesService.doctorRepository(name);
const result = await repositoriesService.doctorRepository(id);
return c.json<DoctorRepositoryDto>(result, 200);
})
.delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
const { name, snapshotId } = c.req.param();
.delete("/:id/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
const { id, snapshotId } = c.req.param();
await repositoriesService.deleteSnapshot(name, snapshotId);
await repositoriesService.deleteSnapshot(id, snapshotId);
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
})
.patch("/:name", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
const { name } = c.req.param();
.patch("/:id", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
const { id } = c.req.param();
const body = c.req.valid("json");
const res = await repositoriesService.updateRepository(name, body);
const res = await repositoriesService.updateRepository(id, body);
return c.json<UpdateRepositoryDto>(res.repository, 200);
});

View File

@@ -61,6 +61,7 @@ export const createRepositoryResponse = type({
message: "string",
repository: type({
id: "string",
shortId: "string",
name: "string",
}),
});
@@ -90,7 +91,7 @@ export const getRepositoryResponse = repositorySchema;
export type GetRepositoryDto = typeof getRepositoryResponse.infer;
export const getRepositoryDto = describeRoute({
description: "Get a single repository by name",
description: "Get a single repository by ID",
tags: ["Repositories"],
operationId: "getRepository",
responses: {

View File

@@ -1,7 +1,6 @@
import crypto from "node:crypto";
import { and, eq, ne } from "drizzle-orm";
import { eq, or } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
@@ -17,6 +16,12 @@ import {
} from "~/schemas/restic";
import { type } from "arktype";
const findRepository = async (idOrShortId: string) => {
return await db.query.repositoriesTable.findFirst({
where: or(eq(repositoriesTable.id, idOrShortId), eq(repositoriesTable.shortId, idOrShortId)),
});
};
const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({});
return repositories;
@@ -58,21 +63,11 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
};
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
const slug = slugify(name, { lower: true, strict: true });
const existing = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, slug),
});
if (existing) {
throw new ConflictError("Repository with this name already exists");
}
const id = crypto.randomUUID();
const shortId = generateShortId();
let processedConfig = config;
if (config.backend === "local") {
if (config.backend === "local" && !config.isExistingRepository) {
processedConfig = { ...config, name: shortId };
}
@@ -83,7 +78,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
.values({
id,
shortId,
name: slug,
name: name.trim(),
type: config.backend,
config: encryptedConfig,
compressionMode: compressionMode ?? "auto",
@@ -124,10 +119,8 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);
};
const getRepository = async (name: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const getRepository = async (id: string) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -136,10 +129,8 @@ const getRepository = async (name: string) => {
return { repository };
};
const deleteRepository = async (name: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const deleteRepository = async (id: string) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -147,21 +138,19 @@ const deleteRepository = async (name: string) => {
// TODO: Add cleanup logic for the actual restic repository files
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, repository.id));
};
/**
* List snapshots for a given repository
* If backupId is provided, filter snapshots by that backup ID (tag)
* @param name Repository name
* @param id Repository ID
* @param backupId Optional backup ID to filter snapshots for a specific backup schedule
*
* @returns List of snapshots
*/
const listSnapshots = async (name: string, backupId?: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const listSnapshots = async (id: string, backupId?: string) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -183,10 +172,8 @@ const listSnapshots = async (name: string, backupId?: string) => {
}
};
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const listSnapshotFiles = async (id: string, snapshotId: string, path?: string) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -216,7 +203,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
};
const restoreSnapshot = async (
name: string,
id: string,
snapshotId: string,
options?: {
include?: string[];
@@ -227,9 +214,7 @@ const restoreSnapshot = async (
overwrite?: OverwriteMode;
},
) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -252,10 +237,8 @@ const restoreSnapshot = async (
}
};
const getSnapshotDetails = async (name: string, snapshotId: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const getSnapshotDetails = async (id: string, snapshotId: string) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -277,9 +260,7 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
};
const checkHealth = async (repositoryId: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, repositoryId),
});
const repository = await findRepository(repositoryId);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -304,10 +285,8 @@ const checkHealth = async (repositoryId: string) => {
}
};
const doctorRepository = async (name: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const doctorRepository = async (id: string) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -395,10 +374,8 @@ const doctorRepository = async (name: string) => {
};
};
const deleteSnapshot = async (name: string, snapshotId: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const deleteSnapshot = async (id: string, snapshotId: string) => {
const repository = await findRepository(id);
if (!repository) {
throw new NotFoundError("Repository not found");
@@ -412,10 +389,8 @@ const deleteSnapshot = async (name: string, snapshotId: string) => {
}
};
const updateRepository = async (name: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
const existing = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
const updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
const existing = await findRepository(id);
if (!existing) {
throw new NotFoundError("Repository not found");
@@ -439,17 +414,7 @@ const updateRepository = async (name: string, updates: { name?: string; compress
let newName = existing.name;
if (updates.name !== undefined && updates.name !== existing.name) {
const newSlug = slugify(updates.name, { lower: true, strict: true });
const conflict = await db.query.repositoriesTable.findFirst({
where: and(eq(repositoriesTable.name, newSlug), ne(repositoriesTable.id, existing.id)),
});
if (conflict) {
throw new ConflictError("A repository with this name already exists");
}
newName = newSlug;
newName = updates.name.trim();
}
const [updated] = await db

View File

@@ -2,5 +2,5 @@ import crypto from "node:crypto";
export const generateShortId = (length = 5): string => {
const bytesNeeded = Math.ceil((length * 3) / 4);
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length);
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length).toLowerCase();
};