mirror of
https://github.com/nicotsx/zerobyte.git
synced 2025-12-23 21:47:47 -05:00
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:
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" })}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
app/drizzle/0023_chemical_deathbird.sql
Normal file
1
app/drizzle/0023_chemical_deathbird.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP INDEX `repositories_table_name_unique`;
|
||||
1
app/drizzle/0025_remarkable_pete_wisdom.sql
Normal file
1
app/drizzle/0025_remarkable_pete_wisdom.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS `repositories_table_name_unique`;
|
||||
832
app/drizzle/meta/0025_snapshot.json
Normal file
832
app/drizzle/meta/0025_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user