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

View file

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

View file

@ -1,7 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon, RotateCcw, Trash2 } from "lucide-react"; import { FileIcon, RotateCcw, Trash2 } from "lucide-react";
import { Link } from "react-router";
import { FileTree } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button, buttonVariants } from "~/client/components/ui/button"; 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 { formatDateTime } from "~/client/lib/datetime";
import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { Link } from "@tanstack/react-router";
interface Props { interface Props {
snapshot: Snapshot; 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 { Database, Plus, RotateCcw } from "lucide-react";
import { useState } from "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 { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RepositoryIcon } from "~/client/components/repository-icon"; import { RepositoryIcon } from "~/client/components/repository-icon";
import { Button } from "~/client/components/ui/button"; 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 { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; 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 { cn } from "~/client/lib/utils";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { useNavigate } from "@tanstack/react-router";
export const handle = { export const handle = {
breadcrumb: () => [{ label: "Repositories" }], breadcrumb: () => [{ label: "Repositories" }],
}; };
export function meta(_: Route.MetaArgs) { export function RepositoriesPage() {
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) {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState(""); const [backendFilter, setBackendFilter] = useState("");
@ -47,20 +29,18 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { data } = useQuery({ const { data } = useSuspenseQuery({
...listRepositoriesOptions(), ...listRepositoriesOptions(),
initialData: loaderData,
}); });
const filteredRepositories = const filteredRepositories = data.filter((repository) => {
data?.filter((repository) => { const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase()); const matchesStatus = !statusFilter || repository.status === statusFilter;
const matchesStatus = !statusFilter || repository.status === statusFilter; const matchesBackend = !backendFilter || repository.type === backendFilter;
const matchesBackend = !backendFilter || repository.type === backendFilter; return matchesSearch && matchesStatus && matchesBackend;
return matchesSearch && matchesStatus && matchesBackend; });
}) || [];
const hasNoRepositories = data?.length === 0; const hasNoRepositories = data.length === 0;
const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories; const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories;
if (hasNoRepositories) { if (hasNoRepositories) {
@ -70,7 +50,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
title="No repository" title="No repository"
description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency." description="Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized for storage efficiency."
button={ button={
<Button onClick={() => navigate("/repositories/create")}> <Button onClick={() => navigate({ to: "/repositories/create" })}>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Create repository Create repository
</Button> </Button>
@ -117,7 +97,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
</Button> </Button>
)} )}
</span> </span>
<Button onClick={() => navigate("/repositories/create")}> <Button onClick={() => navigate({ to: "/repositories/create" })}>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
Create Repository Create Repository
</Button> </Button>
@ -126,7 +106,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
<Table className="border-t"> <Table className="border-t">
<TableHeader className="bg-card-header"> <TableHeader className="bg-card-header">
<TableRow> <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 text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Compression</TableHead> <TableHead className="uppercase hidden sm:table-cell">Compression</TableHead>
<TableHead className="uppercase text-center">Status</TableHead> <TableHead className="uppercase text-center">Status</TableHead>
@ -150,7 +130,7 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
<TableRow <TableRow
key={repository.id} key={repository.id}
className="hover:bg-accent/50 hover:cursor-pointer" 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 className="font-medium text-strong-accent">{repository.name}</TableCell>
<TableCell> <TableCell>

View file

@ -1,46 +1,27 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { redirect, useSearchParams } from "react-router"; import { Suspense, useEffect } from "react";
import { useEffect } from "react";
import { getRepositoryOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { useNavigate, useSearch } from "@tanstack/react-router";
export const handle = { // export const handle = {
breadcrumb: (match: Route.MetaArgs) => [ // breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" }, // { label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.name || match.params.id }, // { label: match.loaderData?.name || match.params.id },
], // ],
}; // };
export function meta({ params, loaderData }: Route.MetaArgs) { export default function RepositoryDetailsPage({ repositoryId }: { repositoryId: string }) {
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) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate();
const activeTab = searchParams.get("tab") || "info"; const { tab } = useSearch({ from: "/(dashboard)/repositories/$repositoryId" });
const activeTab = tab || "info";
const { data } = useQuery({ const { data } = useSuspenseQuery({
...getRepositoryOptions({ path: { id: loaderData.id } }), ...getRepositoryOptions({ path: { id: repositoryId } }),
initialData: loaderData,
}); });
useEffect(() => { useEffect(() => {
@ -49,7 +30,7 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
return ( 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"> <TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger> <TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="snapshots">Snapshots</TabsTrigger> <TabsTrigger value="snapshots">Snapshots</TabsTrigger>
@ -58,7 +39,9 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
<RepositoryInfoTabContent repository={data} /> <RepositoryInfoTabContent repository={data} />
</TabsContent> </TabsContent>
<TabsContent value="snapshots"> <TabsContent value="snapshots">
<RepositorySnapshotsTabContent repository={data} /> <Suspense>
<RepositorySnapshotsTabContent repository={data} />
</Suspense>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</> </>

View file

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

View file

@ -30,7 +30,7 @@ import {
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import { DoctorReport } from "../components/doctor-report"; import { DoctorReport } from "../components/doctor-report";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { useNavigate } from "react-router"; import { useNavigate } from "@tanstack/react-router";
type Props = { type Props = {
repository: Repository; repository: Repository;
@ -73,7 +73,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
...deleteRepositoryMutation(), ...deleteRepositoryMutation(),
onSuccess: () => { onSuccess: () => {
toast.success("Repository deleted successfully"); toast.success("Repository deleted successfully");
void navigate("/repositories"); void navigate({ to: "/repositories" });
}, },
onError: (error) => { onError: (error) => {
toast.error("Failed to delete repository", { 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(); e.preventDefault();
setShowConfirmDialog(true); 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 authLoginRouteImport } from './routes/(auth)/login'
import { Route as authDownloadRecoveryKeyRouteImport } from './routes/(auth)/download-recovery-key' import { Route as authDownloadRecoveryKeyRouteImport } from './routes/(auth)/download-recovery-key'
import { Route as dashboardVolumesIndexRouteImport } from './routes/(dashboard)/volumes/index' 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 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({ const dashboardRouteRoute = dashboardRouteRouteImport.update({
id: '/(dashboard)', id: '/(dashboard)',
@ -46,28 +49,52 @@ const dashboardVolumesIndexRoute = dashboardVolumesIndexRouteImport.update({
path: '/volumes/', path: '/volumes/',
getParentRoute: () => dashboardRouteRoute, getParentRoute: () => dashboardRouteRoute,
} as any) } as any)
const dashboardRepositoriesIndexRoute =
dashboardRepositoriesIndexRouteImport.update({
id: '/repositories/',
path: '/repositories/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardVolumesVolumeIdRoute = const dashboardVolumesVolumeIdRoute =
dashboardVolumesVolumeIdRouteImport.update({ dashboardVolumesVolumeIdRouteImport.update({
id: '/volumes/$volumeId', id: '/volumes/$volumeId',
path: '/volumes/$volumeId', path: '/volumes/$volumeId',
getParentRoute: () => dashboardRouteRoute, getParentRoute: () => dashboardRouteRoute,
} as any) } 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 { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/download-recovery-key': typeof authDownloadRecoveryKeyRoute '/download-recovery-key': typeof authDownloadRecoveryKeyRoute
'/login': typeof authLoginRoute '/login': typeof authLoginRoute
'/onboarding': typeof authOnboardingRoute '/onboarding': typeof authOnboardingRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/repositories/': typeof dashboardRepositoriesIndexRoute
'/volumes/': typeof dashboardVolumesIndexRoute '/volumes/': typeof dashboardVolumesIndexRoute
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/download-recovery-key': typeof authDownloadRecoveryKeyRoute '/download-recovery-key': typeof authDownloadRecoveryKeyRoute
'/login': typeof authLoginRoute '/login': typeof authLoginRoute
'/onboarding': typeof authOnboardingRoute '/onboarding': typeof authOnboardingRoute
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/repositories': typeof dashboardRepositoriesIndexRoute
'/volumes': typeof dashboardVolumesIndexRoute '/volumes': typeof dashboardVolumesIndexRoute
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@ -76,8 +103,11 @@ export interface FileRoutesById {
'/(auth)/download-recovery-key': typeof authDownloadRecoveryKeyRoute '/(auth)/download-recovery-key': typeof authDownloadRecoveryKeyRoute
'/(auth)/login': typeof authLoginRoute '/(auth)/login': typeof authLoginRoute
'/(auth)/onboarding': typeof authOnboardingRoute '/(auth)/onboarding': typeof authOnboardingRoute
'/(dashboard)/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute '/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/(dashboard)/repositories/': typeof dashboardRepositoriesIndexRoute
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute '/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
'/(dashboard)/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@ -86,16 +116,22 @@ export interface FileRouteTypes {
| '/download-recovery-key' | '/download-recovery-key'
| '/login' | '/login'
| '/onboarding' | '/onboarding'
| '/repositories/$repositoryId'
| '/volumes/$volumeId' | '/volumes/$volumeId'
| '/repositories/'
| '/volumes/' | '/volumes/'
| '/repositories/$repoId/$snapshotId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/download-recovery-key' | '/download-recovery-key'
| '/login' | '/login'
| '/onboarding' | '/onboarding'
| '/repositories/$repositoryId'
| '/volumes/$volumeId' | '/volumes/$volumeId'
| '/repositories'
| '/volumes' | '/volumes'
| '/repositories/$repoId/$snapshotId'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@ -103,8 +139,11 @@ export interface FileRouteTypes {
| '/(auth)/download-recovery-key' | '/(auth)/download-recovery-key'
| '/(auth)/login' | '/(auth)/login'
| '/(auth)/onboarding' | '/(auth)/onboarding'
| '/(dashboard)/repositories/$repositoryId'
| '/(dashboard)/volumes/$volumeId' | '/(dashboard)/volumes/$volumeId'
| '/(dashboard)/repositories/'
| '/(dashboard)/volumes/' | '/(dashboard)/volumes/'
| '/(dashboard)/repositories/$repoId/$snapshotId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@ -159,6 +198,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardVolumesIndexRouteImport preLoaderRoute: typeof dashboardVolumesIndexRouteImport
parentRoute: typeof dashboardRouteRoute parentRoute: typeof dashboardRouteRoute
} }
'/(dashboard)/repositories/': {
id: '/(dashboard)/repositories/'
path: '/repositories'
fullPath: '/repositories/'
preLoaderRoute: typeof dashboardRepositoriesIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/volumes/$volumeId': { '/(dashboard)/volumes/$volumeId': {
id: '/(dashboard)/volumes/$volumeId' id: '/(dashboard)/volumes/$volumeId'
path: '/volumes/$volumeId' path: '/volumes/$volumeId'
@ -166,17 +212,39 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardVolumesVolumeIdRouteImport preLoaderRoute: typeof dashboardVolumesVolumeIdRouteImport
parentRoute: typeof dashboardRouteRoute 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 { interface dashboardRouteRouteChildren {
dashboardRepositoriesRepositoryIdRoute: typeof dashboardRepositoriesRepositoryIdRoute
dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute
dashboardRepositoriesIndexRoute: typeof dashboardRepositoriesIndexRoute
dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute
dashboardRepositoriesRepoIdSnapshotIdRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRoute
} }
const dashboardRouteRouteChildren: dashboardRouteRouteChildren = { const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
dashboardRepositoriesRepositoryIdRoute:
dashboardRepositoriesRepositoryIdRoute,
dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute, dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute,
dashboardRepositoriesIndexRoute: dashboardRepositoriesIndexRoute,
dashboardVolumesIndexRoute: dashboardVolumesIndexRoute, dashboardVolumesIndexRoute: dashboardVolumesIndexRoute,
dashboardRepositoriesRepoIdSnapshotIdRoute:
dashboardRepositoriesRepoIdSnapshotIdRoute,
} }
const dashboardRouteRouteWithChildren = dashboardRouteRoute._addFileChildren( const dashboardRouteRouteWithChildren = dashboardRouteRoute._addFileChildren(

View file

@ -11,6 +11,11 @@ client.setConfig({
export function getRouter() { export function getRouter() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries(); 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)")({ export const Route = createFileRoute("/(dashboard)")({
component: PathlessLayoutComponent, component: PathlessLayoutComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
server: { server: {
middleware: [authMiddleware], middleware: [authMiddleware],
}, },

View file

@ -5,6 +5,7 @@ import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
component: RouteComponent, component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => { loader: async ({ params, context }) => {
const res = await context.queryClient.ensureQueryData({ const res = await context.queryClient.ensureQueryData({
...getVolumeOptions({ path: { id: params.volumeId } }), ...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/")({ export const Route = createFileRoute("/(dashboard)/volumes/")({
component: VolumesPage, component: VolumesPage,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ context }) => { loader: async ({ context }) => {
await context.queryClient.ensureQueryData(listVolumesOptions()); 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 type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "~/client/components/ui/sonner";
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
server: { server: {
@ -33,6 +34,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
], ],
}), }),
component: RootLayout, component: RootLayout,
errorComponent: (e) => <div>{e.error.message}</div>,
}); });
function RootLayout() { function RootLayout() {
@ -53,6 +55,7 @@ function RootLayout() {
<Outlet /> <Outlet />
<TanStackRouterDevtools position="bottom-right" /> <TanStackRouterDevtools position="bottom-right" />
<ReactQueryDevtools buttonPosition="bottom-left" /> <ReactQueryDevtools buttonPosition="bottom-left" />
<Toaster />
<Scripts /> <Scripts />
</body> </body>
</html> </html>

View file

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

View file

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

View file

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