refactor: repo & snapshot details

This commit is contained in:
Nicolas Meienberger 2026-02-04 22:54:13 +01:00
parent 86a20cff2e
commit 551618d192
20 changed files with 349 additions and 213 deletions

View file

@ -16,4 +16,4 @@ export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }));
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://192.168.2.42:4096" }));

View file

@ -2,7 +2,7 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: "http://localhost:4096" | (string & {});
baseUrl: "http://192.168.2.42:4096" | (string & {});
};
export type GetStatusData = {
@ -1649,6 +1649,7 @@ export type ListSnapshotsResponses = {
size: number;
tags: Array<string>;
time: number;
hostname?: string;
}>;
};
@ -1718,6 +1719,7 @@ export type GetSnapshotDetailsResponses = {
size: number;
tags: Array<string>;
time: number;
hostname?: string;
};
};

View file

@ -1,7 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
@ -32,6 +31,7 @@ import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-clie
import { parseError } from "~/client/lib/errors";
import type { BackupSchedule, Snapshot } from "../lib/types";
import { cn } from "../lib/utils";
import { Link, useNavigate } from "@tanstack/react-router";
type Props = {
snapshots: Snapshot[];
@ -40,8 +40,8 @@ type Props = {
};
export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
@ -71,7 +71,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
});
const handleRowClick = (snapshotId: string) => {
void navigate(`/repositories/${repositoryId}/${snapshotId}`);
void navigate({ to: `/repositories/${repositoryId}/${snapshotId}` });
};
const toggleSelectAll = () => {
@ -172,7 +172,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
<div className="flex items-center gap-2">
<Link
hidden={!backup}
to={backup ? `/backups/${backup.id}` : "#"}
to={backup ? `/backups/${backup.id}` : "."}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>

View file

@ -1,7 +1,6 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon, RotateCcw, Trash2 } from "lucide-react";
import { Link } from "react-router";
import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button, buttonVariants } from "~/client/components/ui/button";
@ -10,6 +9,7 @@ import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-qu
import { formatDateTime } from "~/client/lib/datetime";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { cn } from "~/client/lib/utils";
import { Link } from "@tanstack/react-router";
interface Props {
snapshot: Snapshot;

View file

@ -1,8 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { Database, Plus, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { listRepositories } from "~/client/api-client/sdk.gen";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { Button } from "~/client/components/ui/button";
@ -10,31 +8,15 @@ import { Card } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import type { Route } from "./+types/repositories";
import { cn } from "~/client/lib/utils";
import { EmptyState } from "~/client/components/empty-state";
import { useNavigate } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Repositories" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Repositories" },
{
name: "description",
content: "Manage your backup repositories with encryption and compression.",
},
];
}
export const clientLoader = async () => {
const repositories = await listRepositories();
if (repositories.data) return repositories.data;
return [];
};
export default function Repositories({ loaderData }: Route.ComponentProps) {
export function RepositoriesPage() {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState("");
@ -47,20 +29,18 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const { data } = useQuery({
const { data } = useSuspenseQuery({
...listRepositoriesOptions(),
initialData: loaderData,
});
const filteredRepositories =
data?.filter((repository) => {
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || repository.status === statusFilter;
const matchesBackend = !backendFilter || repository.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend;
}) || [];
const filteredRepositories = data.filter((repository) => {
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || repository.status === statusFilter;
const matchesBackend = !backendFilter || repository.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend;
});
const hasNoRepositories = data?.length === 0;
const hasNoRepositories = data.length === 0;
const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories;
if (hasNoRepositories) {
@ -70,7 +50,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
title="No repository"
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
button={
<Button onClick={() => navigate("/repositories/create")}>
<Button onClick={() => navigate({ to: "/repositories/create" })}>
<Plus size={16} className="mr-2" />
Create repository
</Button>
@ -117,7 +97,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
</Button>
)}
</span>
<Button onClick={() => navigate("/repositories/create")}>
<Button onClick={() => navigate({ to: "/repositories/create" })}>
<Plus size={16} className="mr-2" />
Create Repository
</Button>
@ -126,7 +106,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="w-25 uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Compression</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
@ -150,7 +130,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
<TableRow
key={repository.id}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/repositories/${repository.shortId}`)}
onClick={() => navigate({ to: `/repositories/${repository.shortId}` })}
>
<TableCell className="font-medium text-strong-accent">{repository.name}</TableCell>
<TableCell>

View file

@ -1,46 +1,27 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { redirect, useSearchParams } from "react-router";
import { useEffect } from "react";
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { Suspense, useEffect } from "react";
import { getRepositoryOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { getRepository } from "~/client/api-client/sdk.gen";
import type { Route } from "./+types/repository-details";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { useNavigate, useSearch } from "@tanstack/react-router";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.name || match.params.id },
],
};
// export const handle = {
// breadcrumb: (match: Route.MetaArgs) => [
// { label: "Repositories", href: "/repositories" },
// { label: match.loaderData?.name || match.params.id },
// ],
// };
export function meta({ params, loaderData }: Route.MetaArgs) {
return [
{ title: `Zerobyte - ${loaderData?.name || params.id}` },
{
name: "description",
content: "View repository configuration, status, and snapshots.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const repository = await getRepository({ path: { id: params.id ?? "" } });
if (repository.data) return repository.data;
return redirect("/repositories");
};
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
export default function RepositoryDetailsPage({ repositoryId }: { repositoryId: string }) {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "info";
const navigate = useNavigate();
const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId" });
const activeTab = tab || "info";
const { data } = useQuery({
...getRepositoryOptions({ path: { id: loaderData.id } }),
initialData: loaderData,
const { data } = useSuspenseQuery({
...getRepositoryOptions({ path: { id: repositoryId } }),
});
useEffect(() => {
@ -49,7 +30,7 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
return (
<>
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })}>
<Tabs value={activeTab} onValueChange={(value) => navigate({ to: ".", search: (s) => ({ ...s, tab: value }) })}>
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="snapshots">Snapshots</TabsTrigger>
@ -58,7 +39,9 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
<RepositoryInfoTabContent repository={data} />
</TabsContent>
<TabsContent value="snapshots">
<RepositorySnapshotsTabContent repository={data} />
<Suspense>
<RepositorySnapshotsTabContent repository={data} />
</Suspense>
</TabsContent>
</Tabs>
</>

View file

@ -1,96 +1,94 @@
import { useQuery } from "@tanstack/react-query";
import { redirect, useParams, Link, Await } from "react-router";
import { listBackupSchedulesOptions, listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import {
getRepositoryOptions,
getSnapshotDetailsOptions,
listBackupSchedulesOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { formatDateTime } from "~/client/lib/datetime";
import { parseError } from "~/client/lib/errors";
import { getRepository, getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details";
import { Suspense, useState } from "react";
import { useState } from "react";
import { Database } from "lucide-react";
import { Link, useParams } from "@tanstack/react-router";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
{ label: match.params.snapshotId },
],
// export const handle = {
// breadcrumb: (match: Route.MetaArgs) => [
// { label: "Repositories", href: "/repositories" },
// { label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
// { label: match.params.snapshotId },
// ],
// };
export const SnapshotError = () => {
const { repoId } = useParams({ from: "/(dashboard)/repositories/$repoId/$snapshotId" });
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<Database className="mb-4 h-12 w-12 text-destructive" />
<p className="text-destructive font-semibold">Snapshot not found</p>
<p className="text-sm text-muted-foreground mt-2">This snapshot does not exist in this repository</p>
<p className="text-sm text-muted-foreground mt-1">It may have been deleted manually outside of Zerobyte.</p>
<div className="mt-4">
<Link
to={`/repositories/$repositoryId`}
search={() => ({ tab: "snapshots" })}
params={{ repositoryId: repoId }}
>
<Button variant="outline">Back to repository</Button>
</Link>
</div>
</CardContent>
</Card>
);
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Browse and restore files from a backup snapshot.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const [snapshot, repository] = await Promise.all([
getSnapshotDetails({
path: { id: params.id, snapshotId: params.snapshotId },
}),
getRepository({ path: { id: params.id } }),
]);
if (!repository.data) return redirect("/repositories");
return {
snapshot: snapshot,
repository: repository.data,
snapshotError: parseError(snapshot.error)?.message ?? null,
};
const FilebrowserFallback = ({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) => {
return (
<SnapshotFileBrowser
repositoryId={repositoryId}
snapshot={{
duration: 0,
paths: [],
short_id: snapshotId,
size: 0,
tags: [],
time: 0,
hostname: "",
retentionCategories: [],
}}
/>
);
};
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
const { id, snapshotId } = useParams<{
id: string;
snapshotId: string;
}>();
export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) {
const [showAllPaths, setShowAllPaths] = useState(false);
const { data } = useQuery({
...listSnapshotFilesOptions({
path: { id: id ?? "", snapshotId: snapshotId ?? "" },
query: { path: "/" },
}),
enabled: !!id && !!snapshotId && !loaderData.snapshotError,
const { data: repository } = useSuspenseQuery({
...getRepositoryOptions({ path: { id: repositoryId } }),
});
const schedules = useQuery({
const { data: schedules } = useSuspenseQuery({
...listBackupSchedulesOptions(),
});
if (!id || !snapshotId) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Invalid snapshot reference</p>
</div>
);
}
const { data, error } = useQuery({
...getSnapshotDetailsOptions({ path: { id: repositoryId, snapshotId: snapshotId } }),
});
const backupSchedule = schedules?.find((s) => data?.tags.includes(s.shortId));
if (loaderData.snapshotError) {
if (error) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<Database className="mb-4 h-12 w-12 text-destructive" />
<p className="text-destructive font-semibold">Snapshot not found</p>
<p className="text-sm text-muted-foreground mt-2">
This snapshot does not exist in {loaderData.repository.name}.
</p>
<p className="text-sm text-muted-foreground mt-1">It may have been deleted manually outside of Zerobyte.</p>
<div className="mt-4">
<Link to={`/repositories/${id}?tab=snapshots`}>
<Button variant="outline">Back to repository</Button>
</Link>
<>
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold">{repository.name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
</CardContent>
</Card>
</div>
<SnapshotError />
</>
);
}
@ -98,29 +96,18 @@ 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">{loaderData.repository.name}</h1>
<h1 className="text-2xl font-bold">{repository.name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
</div>
<Suspense
fallback={
<SnapshotFileBrowser
repositoryId={id}
snapshot={{ duration: 0, paths: [], short_id: "", size: 0, tags: [], time: 0, retentionCategories: [] }}
/>
}
>
<Await resolve={loaderData.snapshot}>
{(value) => {
if (!value.data) return <div className="text-destructive">Snapshot data not found.</div>;
{data ? (
<SnapshotFileBrowser repositoryId={repositoryId} snapshot={data} />
) : (
<FilebrowserFallback repositoryId={repositoryId} snapshotId={snapshotId} />
)}
return <SnapshotFileBrowser repositoryId={id} snapshot={value.data} />;
}}
</Await>
</Suspense>
{data?.snapshot && (
{data && (
<Card>
<CardHeader>
<CardTitle>Snapshot Information</CardTitle>
@ -129,69 +116,58 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground">Snapshot ID:</span>
<p className="font-mono break-all">{data.snapshot.id}</p>
<p className="font-mono break-all">{data.short_id}</p>
</div>
<div>
<span className="text-muted-foreground">Short ID:</span>
<p className="font-mono break-all">{data.snapshot.short_id}</p>
<p className="font-mono break-all">{data.short_id}</p>
</div>
<div>
<span className="text-muted-foreground">Hostname:</span>
<p>{data.snapshot.hostname}</p>
<p>{data.hostname}</p>
</div>
<div>
<span className="text-muted-foreground">Time:</span>
<p>{formatDateTime(data.snapshot.time)}</p>
<p>{formatDateTime(data.time)}</p>
</div>
<Suspense fallback={<div>Loading...</div>}>
<Await resolve={loaderData.snapshot}>
{(value) => {
if (!value.data) return null;
const backupSchedule = schedules.data?.find((s) => value.data.tags.includes(s.shortId));
return (
<>
<div>
<span className="text-muted-foreground">Backup Schedule:</span>
<p>
<Link to={`/backups/${backupSchedule?.id}`} className="text-primary hover:underline">
{backupSchedule?.name}
</Link>
</p>
</div>
<div>
<span className="text-muted-foreground">Volume:</span>
<p>
<Link
to={`/volumes/${backupSchedule?.volume.shortId}`}
className="text-primary hover:underline"
>
{backupSchedule?.volume.name}
</Link>
</p>
</div>
</>
);
}}
</Await>
</Suspense>
<>
<div>
<span className="text-muted-foreground">Backup Schedule:</span>
<p>
<Link to={`/backups/$backupScheduleId`} className="text-primary hover:underline">
{backupSchedule?.name}
</Link>
</p>
</div>
<div>
<span className="text-muted-foreground">Volume:</span>
<p>
<Link
to={`/volumes/$volumeId`}
className="text-primary hover:underline"
params={{ volumeId: backupSchedule?.volume.shortId || "" }}
>
{backupSchedule?.volume.name}
</Link>
</p>
</div>
</>
<div className="col-span-2">
<span className="text-muted-foreground">Paths:</span>
<div className="space-y-1 mt-1">
{data.snapshot.paths.slice(0, showAllPaths ? undefined : 20).map((path) => (
{data.paths.slice(0, showAllPaths ? undefined : 20).map((path) => (
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
{path}
</p>
))}
{data.snapshot.paths.length > 20 && (
{data.paths.length > 20 && (
<button
type="button"
onClick={() => setShowAllPaths(!showAllPaths)}
className="text-xs text-primary hover:underline mt-1"
>
{showAllPaths ? "Show less" : `+ ${data.snapshot.paths.length - 20} more`}
{showAllPaths ? "Show less" : `+ ${data.paths.length - 20} more`}
</button>
)}
</div>

View file

@ -30,7 +30,7 @@ import {
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import { DoctorReport } from "../components/doctor-report";
import { parseError } from "~/client/lib/errors";
import { useNavigate } from "react-router";
import { useNavigate } from "@tanstack/react-router";
type Props = {
repository: Repository;
@ -73,7 +73,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
...deleteRepositoryMutation(),
onSuccess: () => {
toast.success("Repository deleted successfully");
void navigate("/repositories");
void navigate({ to: "/repositories" });
},
onError: (error) => {
toast.error("Failed to delete repository", {
@ -115,7 +115,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
},
});
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = (e: React.SubmitEvent) => {
e.preventDefault();
setShowConfirmDialog(true);
};

View file

@ -15,7 +15,10 @@ import { Route as authOnboardingRouteImport } from './routes/(auth)/onboarding'
import { Route as authLoginRouteImport } from './routes/(auth)/login'
import { Route as authDownloadRecoveryKeyRouteImport } from './routes/(auth)/download-recovery-key'
import { Route as dashboardVolumesIndexRouteImport } from './routes/(dashboard)/volumes/index'
import { Route as dashboardRepositoriesIndexRouteImport } from './routes/(dashboard)/repositories/index'
import { Route as dashboardVolumesVolumeIdRouteImport } from './routes/(dashboard)/volumes/$volumeId'
import { Route as dashboardRepositoriesRepositoryIdRouteImport } from './routes/(dashboard)/repositories/$repositoryId'
import { Route as dashboardRepositoriesRepoIdSnapshotIdRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapshotId'
const dashboardRouteRoute = dashboardRouteRouteImport.update({
id: '/(dashboard)',
@ -46,28 +49,52 @@ const dashboardVolumesIndexRoute = dashboardVolumesIndexRouteImport.update({
path: '/volumes/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesIndexRoute =
dashboardRepositoriesIndexRouteImport.update({
id: '/repositories/',
path: '/repositories/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardVolumesVolumeIdRoute =
dashboardVolumesVolumeIdRouteImport.update({
id: '/volumes/$volumeId',
path: '/volumes/$volumeId',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepositoryIdRoute =
dashboardRepositoriesRepositoryIdRouteImport.update({
id: '/repositories/$repositoryId',
path: '/repositories/$repositoryId',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepoIdSnapshotIdRoute =
dashboardRepositoriesRepoIdSnapshotIdRouteImport.update({
id: '/repositories/$repoId/$snapshotId',
path: '/repositories/$repoId/$snapshotId',
getParentRoute: () => dashboardRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/download-recovery-key': typeof authDownloadRecoveryKeyRoute
'/login': typeof authLoginRoute
'/onboarding': typeof authOnboardingRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/repositories/': typeof dashboardRepositoriesIndexRoute
'/volumes/': typeof dashboardVolumesIndexRoute
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/download-recovery-key': typeof authDownloadRecoveryKeyRoute
'/login': typeof authLoginRoute
'/onboarding': typeof authOnboardingRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/repositories': typeof dashboardRepositoriesIndexRoute
'/volumes': typeof dashboardVolumesIndexRoute
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -76,8 +103,11 @@ export interface FileRoutesById {
'/(auth)/download-recovery-key': typeof authDownloadRecoveryKeyRoute
'/(auth)/login': typeof authLoginRoute
'/(auth)/onboarding': typeof authOnboardingRoute
'/(dashboard)/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/(dashboard)/repositories/': typeof dashboardRepositoriesIndexRoute
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
'/(dashboard)/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -86,16 +116,22 @@ export interface FileRouteTypes {
| '/download-recovery-key'
| '/login'
| '/onboarding'
| '/repositories/$repositoryId'
| '/volumes/$volumeId'
| '/repositories/'
| '/volumes/'
| '/repositories/$repoId/$snapshotId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/download-recovery-key'
| '/login'
| '/onboarding'
| '/repositories/$repositoryId'
| '/volumes/$volumeId'
| '/repositories'
| '/volumes'
| '/repositories/$repoId/$snapshotId'
id:
| '__root__'
| '/'
@ -103,8 +139,11 @@ export interface FileRouteTypes {
| '/(auth)/download-recovery-key'
| '/(auth)/login'
| '/(auth)/onboarding'
| '/(dashboard)/repositories/$repositoryId'
| '/(dashboard)/volumes/$volumeId'
| '/(dashboard)/repositories/'
| '/(dashboard)/volumes/'
| '/(dashboard)/repositories/$repoId/$snapshotId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -159,6 +198,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardVolumesIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/': {
id: '/(dashboard)/repositories/'
path: '/repositories'
fullPath: '/repositories/'
preLoaderRoute: typeof dashboardRepositoriesIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/volumes/$volumeId': {
id: '/(dashboard)/volumes/$volumeId'
path: '/volumes/$volumeId'
@ -166,17 +212,39 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardVolumesVolumeIdRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repositoryId': {
id: '/(dashboard)/repositories/$repositoryId'
path: '/repositories/$repositoryId'
fullPath: '/repositories/$repositoryId'
preLoaderRoute: typeof dashboardRepositoriesRepositoryIdRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repoId/$snapshotId': {
id: '/(dashboard)/repositories/$repoId/$snapshotId'
path: '/repositories/$repoId/$snapshotId'
fullPath: '/repositories/$repoId/$snapshotId'
preLoaderRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRouteImport
parentRoute: typeof dashboardRouteRoute
}
}
}
interface dashboardRouteRouteChildren {
dashboardRepositoriesRepositoryIdRoute: typeof dashboardRepositoriesRepositoryIdRoute
dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute
dashboardRepositoriesIndexRoute: typeof dashboardRepositoriesIndexRoute
dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute
dashboardRepositoriesRepoIdSnapshotIdRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRoute
}
const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
dashboardRepositoriesRepositoryIdRoute:
dashboardRepositoriesRepositoryIdRoute,
dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute,
dashboardRepositoriesIndexRoute: dashboardRepositoriesIndexRoute,
dashboardVolumesIndexRoute: dashboardVolumesIndexRoute,
dashboardRepositoriesRepoIdSnapshotIdRoute:
dashboardRepositoriesRepoIdSnapshotIdRoute,
}
const dashboardRouteRouteWithChildren = dashboardRouteRoute._addFileChildren(

View file

@ -11,6 +11,11 @@ client.setConfig({
export function getRouter() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
mutationCache: new MutationCache({
onSuccess: () => {
void queryClient.invalidateQueries();

View file

@ -0,0 +1,44 @@
import { createFileRoute } from "@tanstack/react-router";
import {
getRepositoryOptions,
getSnapshotDetailsOptions,
listSnapshotFilesOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details";
export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapshotId")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
await context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { id: params.repoId } }),
});
context.queryClient.prefetchQuery({
...getSnapshotDetailsOptions({
path: { id: params.repoId, snapshotId: params.snapshotId },
}),
});
context.queryClient.prefetchQuery({
...listSnapshotFilesOptions({
path: { id: params.repoId, snapshotId: params.snapshotId },
query: { path: "/" },
}),
});
},
head: ({ params }) => ({
meta: [
{ title: `Zerobyte - ${params.snapshotId}` },
{
name: "description",
content: "Browse and restore files from a backup snapshot.",
},
],
}),
});
function RouteComponent() {
const { repoId, snapshotId } = Route.useParams();
return <SnapshotDetailsPage repositoryId={repoId} snapshotId={snapshotId} />;
}

View file

@ -0,0 +1,43 @@
import { createFileRoute } from "@tanstack/react-router";
import { type } from "arktype";
import {
getRepositoryOptions,
listBackupSchedulesOptions,
listSnapshotsOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import RepositoryDetailsPage from "~/client/modules/repositories/routes/repository-details";
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
context.queryClient.prefetchQuery({
...listSnapshotsOptions({ path: { id: params.repositoryId } }),
})
context.queryClient.prefetchQuery({
...listBackupSchedulesOptions(),
})
const res = await context.queryClient.ensureQueryData({
...getRepositoryOptions({ path: { id: params.repositoryId } }),
})
return res;
},
validateSearch: type({ tab: "string?" }),
head: ({ loaderData }) => ({
meta: [
{ title: `Zerobyte - ${loaderData?.name}` },
{
name: "description",
content: "View repository configuration, status, and snapshots.",
},
],
}),
});
function RouteComponent() {
const { repositoryId } = Route.useParams();
return <RepositoryDetailsPage repositoryId={repositoryId} />;
}

View file

@ -0,0 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RepositoriesPage } from "~/client/modules/repositories/routes/repositories";
export const Route = createFileRoute("/(dashboard)/repositories/")({
component: RouteComponent,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData({
...listRepositoriesOptions(),
});
},
errorComponent: (e) => <div>{e.error.message}</div>,
head: () => ({
meta: [
{ title: "Zerobyte - Repositories" },
{
name: "description",
content: "Manage your backup repositories with encryption and compression.",
},
],
}),
});
function RouteComponent() {
return <RepositoriesPage />;
}

View file

@ -15,6 +15,7 @@ const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
export const Route = createFileRoute("/(dashboard)")({
component: PathlessLayoutComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
server: {
middleware: [authMiddleware],
},

View file

@ -5,6 +5,7 @@ import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
const res = await context.queryClient.ensureQueryData({
...getVolumeOptions({ path: { id: params.volumeId } }),

View file

@ -4,6 +4,7 @@ import { VolumesPage } from "~/client/modules/volumes/routes/volumes";
export const Route = createFileRoute("/(dashboard)/volumes/")({
component: VolumesPage,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(listVolumesOptions());
},
@ -16,5 +17,4 @@ export const Route = createFileRoute("/(dashboard)/volumes/")({
},
],
}),
errorComponent: (err) => <div>Failed to load volumes {JSON.stringify(err.error)}</div>,
});

View file

@ -4,6 +4,7 @@ import { apiClientMiddleware } from "~/middleware/api-client";
import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "~/client/components/ui/sonner";
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
server: {
@ -33,6 +34,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
],
}),
component: RootLayout,
errorComponent: (e) => <div>{e.error.message}</div>,
});
function RootLayout() {
@ -53,6 +55,7 @@ function RootLayout() {
<Outlet />
<TanStackRouterDevtools position="bottom-right" />
<ReactQueryDevtools buttonPosition="bottom-left" />
<Toaster />
<Scripts />
</body>
</html>

View file

@ -142,6 +142,7 @@ export const repositoriesController = new Hono()
duration,
time: new Date(snapshot.time).getTime(),
paths: snapshot.paths,
hostname: snapshot.hostname,
size: snapshot.summary?.total_bytes_processed || 0,
tags: snapshot.tags ?? [],
retentionCategories: [],

View file

@ -179,6 +179,7 @@ export const snapshotSchema = type({
duration: "number",
tags: "string[]",
retentionCategories: "string[]",
hostname: "string?",
});
const listSnapshotsResponse = snapshotSchema.array();

View file

@ -315,6 +315,8 @@ const getSnapshotDetails = async (id: string, snapshotId: string) => {
const organizationId = getOrganizationId();
const repository = await findRepository(id);
console.log("Getting snapshot details for:", id, snapshotId);
if (!repository) {
throw new NotFoundError("Repository not found");
}