From 825d46c934cdf3449ba1917984302c9e07eccd97 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:41:06 +0100 Subject: [PATCH] refactor: react-router -> tanstack start (#498) * refactor: move to tanstack start * refactor: auth flow & volumes * refactor: repo & snapshot details * refactor: backups, create repo, volumes * refactor: create volume & restore snapshot * refactor: notifications * refactor: settings * refactor: breadcrumbs * fix: ts issues * refactor: prod deployment * fix: import css production * refactor: nitro build * refactor: winston -> consola * fix: memory leak is sse events cleanup * fix: cli usage * chore: remove rr routes file * refactor: pr feedbacks * refactor: patch api client to have a global client per call * refactor: pr feedbacks * fix(dockerfile): add explicit port * fix(e2e): healthcheck under /api --- .dockerignore | 4 + .github/workflows/e2e.yml | 2 +- .gitignore | 1 + Dockerfile | 8 +- app/client/api-client/client.gen.ts | 28 +- app/client/api-client/types.gen.ts | 2 + app/client/components/app-breadcrumb.tsx | 15 +- app/client/components/app-sidebar.tsx | 6 +- app/client/components/layout.tsx | 26 +- app/client/components/restore-form.tsx | 6 +- app/client/components/snapshots-table.tsx | 9 +- app/client/components/sortable-card.tsx | 17 +- app/client/lib/auth-client.ts | 9 +- app/client/lib/types.ts | 5 + .../auth/routes/download-recovery-key.tsx | 22 +- app/client/modules/auth/routes/login.tsx | 26 +- app/client/modules/auth/routes/onboarding.tsx | 17 +- .../backups/components/backup-card.tsx | 4 +- .../components/schedule-mirrors-config.tsx | 49 +- .../schedule-notifications-config.tsx | 32 +- .../backups/components/schedule-summary.tsx | 14 +- .../components/snapshot-file-browser.tsx | 9 +- .../modules/backups/routes/backup-details.tsx | 74 +- app/client/modules/backups/routes/backups.tsx | 39 +- .../modules/backups/routes/create-backup.tsx | 45 +- .../backups/routes/restore-snapshot.tsx | 62 -- .../routes/create-notification.tsx | 23 +- .../routes/notification-details.tsx | 39 +- .../notifications/routes/notifications.tsx | 59 +- .../repositories/routes/create-repository.tsx | 23 +- .../repositories/routes/repositories.tsx | 54 +- .../routes/repository-details.tsx | 50 +- .../repositories/routes/restore-snapshot.tsx | 53 +- .../repositories/routes/snapshot-details.tsx | 238 +++-- app/client/modules/repositories/tabs/info.tsx | 6 +- .../modules/settings/routes/settings.tsx | 44 +- .../modules/volumes/routes/create-volume.tsx | 23 +- .../modules/volumes/routes/volume-details.tsx | 57 +- app/client/modules/volumes/routes/volumes.tsx | 45 +- app/client/modules/volumes/tabs/info.tsx | 4 +- app/client/routes/root.tsx | 9 - app/context.ts | 9 +- app/lib/request-client.ts | 61 ++ app/middleware/api-client.ts | 17 + app/middleware/auth.ts | 30 +- app/root.tsx | 96 -- app/routeTree.gen.ts | 534 +++++++++++ app/router.tsx | 52 ++ app/routes.ts | 26 - app/routes/(auth)/download-recovery-key.tsx | 15 + app/routes/(auth)/login.tsx | 15 + app/routes/(auth)/onboarding.tsx | 15 + .../backups/$backupId.$snapshotId.restore.tsx | 55 ++ .../(dashboard)/backups/$scheduleId.tsx | 53 ++ app/routes/(dashboard)/backups/create.tsx | 20 + app/routes/(dashboard)/backups/index.tsx | 28 + .../notifications/$notificationId.tsx | 33 + .../(dashboard)/notifications/create.tsx | 25 + .../(dashboard)/notifications/index.tsx | 26 + .../repositories/$repoId.$snapId.restore.tsx | 49 + .../repositories/$repoId.$snapshotId.tsx | 53 ++ .../repositories/$repositoryId.tsx | 49 + .../(dashboard)/repositories/create.tsx | 25 + app/routes/(dashboard)/repositories/index.tsx | 29 + app/routes/(dashboard)/route.tsx | 33 + app/routes/(dashboard)/settings/index.tsx | 22 + app/routes/(dashboard)/volumes/$volumeId.tsx | 36 + app/routes/(dashboard)/volumes/create.tsx | 22 + app/routes/(dashboard)/volumes/index.tsx | 23 + app/routes/__root.tsx | 62 ++ app/routes/api.$.ts | 18 + app/routes/index.tsx | 16 + app/server.ts | 53 ++ app/server/app.ts | 2 +- app/server/core/config.ts | 5 +- app/server/index.ts | 50 - app/server/lib/auth.ts | 2 + .../__tests__/events.controller.test.ts | 27 + .../modules/events/events.controller.ts | 42 +- .../repositories/repositories.controller.ts | 1 + .../modules/repositories/repositories.dto.ts | 1 + app/server/utils/logger.ts | 102 ++- bun.lock | 854 ++++++++---------- docker-compose.yml | 3 +- openapi-ts.config.ts | 5 +- package.json | 31 +- react-router.config.ts | 9 - scripts/patch-api-client.ts | 50 + tsconfig.json | 2 +- vite.config.ts | 25 +- 90 files changed, 2496 insertions(+), 1573 deletions(-) delete mode 100644 app/client/modules/backups/routes/restore-snapshot.tsx delete mode 100644 app/client/routes/root.tsx create mode 100644 app/lib/request-client.ts create mode 100644 app/middleware/api-client.ts delete mode 100644 app/root.tsx create mode 100644 app/routeTree.gen.ts create mode 100644 app/router.tsx delete mode 100644 app/routes.ts create mode 100644 app/routes/(auth)/download-recovery-key.tsx create mode 100644 app/routes/(auth)/login.tsx create mode 100644 app/routes/(auth)/onboarding.tsx create mode 100644 app/routes/(dashboard)/backups/$backupId.$snapshotId.restore.tsx create mode 100644 app/routes/(dashboard)/backups/$scheduleId.tsx create mode 100644 app/routes/(dashboard)/backups/create.tsx create mode 100644 app/routes/(dashboard)/backups/index.tsx create mode 100644 app/routes/(dashboard)/notifications/$notificationId.tsx create mode 100644 app/routes/(dashboard)/notifications/create.tsx create mode 100644 app/routes/(dashboard)/notifications/index.tsx create mode 100644 app/routes/(dashboard)/repositories/$repoId.$snapId.restore.tsx create mode 100644 app/routes/(dashboard)/repositories/$repoId.$snapshotId.tsx create mode 100644 app/routes/(dashboard)/repositories/$repositoryId.tsx create mode 100644 app/routes/(dashboard)/repositories/create.tsx create mode 100644 app/routes/(dashboard)/repositories/index.tsx create mode 100644 app/routes/(dashboard)/route.tsx create mode 100644 app/routes/(dashboard)/settings/index.tsx create mode 100644 app/routes/(dashboard)/volumes/$volumeId.tsx create mode 100644 app/routes/(dashboard)/volumes/create.tsx create mode 100644 app/routes/(dashboard)/volumes/index.tsx create mode 100644 app/routes/__root.tsx create mode 100644 app/routes/api.$.ts create mode 100644 app/routes/index.tsx create mode 100644 app/server.ts delete mode 100644 app/server/index.ts delete mode 100644 react-router.config.ts create mode 100755 scripts/patch-api-client.ts diff --git a/.dockerignore b/.dockerignore index 1322a729..d9bea189 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ !turbo.json !bun.lock !package.json +!server.ts +!.gitignore !**/package.json !**/bun.lock @@ -22,3 +24,5 @@ !LICENSES/** node_modules/** +dist/** +.output/** diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 35d83aa2..b2fbb804 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -57,7 +57,7 @@ jobs: - name: Wait for zerobyte to be ready run: | - timeout 30s bash -c 'until curl -f http://localhost:4096/healthcheck; do echo "Waiting for server..." && sleep 2; done' + timeout 30s bash -c 'until curl -f http://localhost:4096/api/healthcheck; do echo "Waiting for server..." && sleep 2; done' - name: Print docker logs if failed to start if: failure() diff --git a/.gitignore b/.gitignore index b0cc64c6..6d1d89b2 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ playwright/temp # OpenAPI error logs openapi-ts-error-*.log +.output diff --git a/Dockerfile b/Dockerfile index a743cf9e..f069687f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,6 +79,7 @@ FROM base AS builder ARG APP_VERSION=dev ENV VITE_APP_VERSION=${APP_VERSION} +ENV PORT=4096 WORKDIR /app @@ -94,6 +95,7 @@ FROM base AS production ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} ENV NODE_ENV="production" +ENV PORT=4096 WORKDIR /app @@ -103,8 +105,7 @@ RUN bun install --production --frozen-lockfile && rm -rf $HOME/.bun/install/cach COPY --from=deps /deps/restic /usr/local/bin/restic COPY --from=deps /deps/rclone /usr/local/bin/rclone COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr -COPY --from=builder /app/dist/client ./dist/client -COPY --from=builder /app/dist/server ./dist/server +COPY --from=builder /app/.output ./.output COPY --from=builder /app/app/drizzle ./assets/migrations # Include third-party licenses and attribution @@ -114,5 +115,4 @@ COPY ./LICENSE ./LICENSE.md EXPOSE 4096 -CMD ["bun", "run", "start"] - +CMD ["bun", ".output/server/index.mjs"] diff --git a/app/client/api-client/client.gen.ts b/app/client/api-client/client.gen.ts index 457f3c3d..a5761642 100644 --- a/app/client/api-client/client.gen.ts +++ b/app/client/api-client/client.gen.ts @@ -1,19 +1,27 @@ // @ts-nocheck // This file is auto-generated by @hey-api/openapi-ts - import { type ClientOptions, type Config, createClient, createConfig } from "./client"; import type { ClientOptions as ClientOptions2 } from "./types.gen"; +import { getRequestClient } from "../../lib/request-client"; -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ export type CreateClientConfig = ( override?: Config, ) => Config & T>; -export const client = createClient(createConfig({ baseUrl: "http://localhost:4096" })); +const fallbackClient = createClient(createConfig({ baseUrl: "http://localhost:4096" })); + +/** + * Proxy client that automatically uses the per-request client from request-scoped server storage. + * Falls back to a default client if no request context is available. + */ +export const client = new Proxy(fallbackClient, { + get(target, prop, receiver) { + try { + const requestClient = getRequestClient(); + return Reflect.get(requestClient, prop, receiver); + } catch { + // No request context available, use fallback + return Reflect.get(target, prop, receiver); + } + }, +}); diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 46689078..0492947e 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1649,6 +1649,7 @@ export type ListSnapshotsResponses = { size: number; tags: Array; time: number; + hostname?: string; }>; }; @@ -1718,6 +1719,7 @@ export type GetSnapshotDetailsResponses = { size: number; tags: Array; time: number; + hostname?: string; }; }; diff --git a/app/client/components/app-breadcrumb.tsx b/app/client/components/app-breadcrumb.tsx index 6f602f62..d419708a 100644 --- a/app/client/components/app-breadcrumb.tsx +++ b/app/client/components/app-breadcrumb.tsx @@ -1,4 +1,4 @@ -import { Link, useMatches, type UIMatch } from "react-router"; +import { useMatches, Link } from "@tanstack/react-router"; import { Breadcrumb, BreadcrumbItem, @@ -13,25 +13,22 @@ export interface BreadcrumbItemData { href?: string; } -interface RouteHandle { - breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null; -} +type BreadcrumbFunction = (match: ReturnType[number]) => BreadcrumbItemData[] | null; export function AppBreadcrumb() { const matches = useMatches(); - // Find the last match with a breadcrumb handler const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => { - const handle = match.handle as RouteHandle | undefined; - return handle?.breadcrumb; + const breadcrumbFn = match.staticData?.breadcrumb as BreadcrumbFunction | undefined; + return breadcrumbFn; }); if (!lastMatchWithBreadcrumb) { return null; } - const handle = lastMatchWithBreadcrumb.handle as RouteHandle; - const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb); + const breadcrumbFn = lastMatchWithBreadcrumb.staticData?.breadcrumb as BreadcrumbFunction; + const breadcrumbs = breadcrumbFn?.(lastMatchWithBreadcrumb); if (!breadcrumbs || breadcrumbs.length === 0) { return null; diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index 2ed0cd5e..9081013e 100644 --- a/app/client/components/app-sidebar.tsx +++ b/app/client/components/app-sidebar.tsx @@ -1,5 +1,4 @@ import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react"; -import { Link, NavLink } from "react-router"; import { useState } from "react"; import { Sidebar, @@ -19,6 +18,7 @@ import { cn } from "~/client/lib/utils"; import { APP_VERSION, RCLONE_VERSION, RESTIC_VERSION, SHOUTRRR_VERSION } from "~/client/lib/version"; import { useUpdates } from "~/client/hooks/use-updates"; import { ReleaseNotesDialog } from "./release-notes-dialog"; +import { Link } from "@tanstack/react-router"; const items = [ { @@ -83,14 +83,14 @@ export function AppSidebar() { - + {({ isActive }) => ( <> {item.title} )} - + diff --git a/app/client/components/layout.tsx b/app/client/components/layout.tsx index 5d9d5fc3..e0ee8a14 100644 --- a/app/client/components/layout.tsx +++ b/app/client/components/layout.tsx @@ -1,37 +1,27 @@ import { LifeBuoy } from "lucide-react"; -import { Outlet, redirect, useNavigate } from "react-router"; import { toast } from "sonner"; -import { appContext } from "~/context"; -import { authMiddleware } from "~/middleware/auth"; -import type { Route } from "./+types/layout"; -import { AppBreadcrumb } from "./app-breadcrumb"; +import { type AppContext } from "~/context"; import { GridBackground } from "./grid-background"; import { Button } from "./ui/button"; import { SidebarProvider, SidebarTrigger } from "./ui/sidebar"; import { AppSidebar } from "./app-sidebar"; import { authClient } from "../lib/auth-client"; import { DevPanelListener } from "./dev-panel-listener"; +import { Outlet, useNavigate } from "@tanstack/react-router"; +import { AppBreadcrumb } from "./app-breadcrumb"; -export const clientMiddleware = [authMiddleware]; +type Props = { + loaderData: AppContext; +}; -export async function clientLoader({ context }: Route.LoaderArgs) { - const ctx = context.get(appContext); - - if (ctx.user && !ctx.user.hasDownloadedResticPassword) { - throw redirect("/download-recovery-key"); - } - - return ctx; -} - -export default function Layout({ loaderData }: Route.ComponentProps) { +export function Layout({ loaderData }: Props) { const navigate = useNavigate(); const handleLogout = async () => { await authClient.signOut({ fetchOptions: { onSuccess: () => { - void navigate("/login", { replace: true }); + void navigate({ to: "/login", replace: true }); }, onError: ({ error }) => { toast.error("Logout failed", { description: error.message }); diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index b6a8c6b4..043e9b3f 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "sonner"; import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react"; import { Button } from "~/client/components/ui/button"; @@ -16,6 +15,7 @@ import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; import type { Repository, Snapshot } from "~/client/lib/types"; import { handleRepositoryError } from "~/client/lib/errors"; +import { useNavigate } from "@tanstack/react-router"; type RestoreLocation = "original" | "custom"; @@ -101,7 +101,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re ...restoreSnapshotMutation(), onSuccess: () => { toast.success("Restore completed"); - void navigate(returnPath); + void navigate({ to: returnPath }); }, onError: (error) => { handleRepositoryError("Restore failed", error, repository.id); @@ -156,7 +156,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re

- @@ -84,13 +59,13 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
setSearchQuery(e.target.value)} /> +
@@ -318,7 +302,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) { - + diff --git a/app/client/modules/volumes/routes/create-volume.tsx b/app/client/modules/volumes/routes/create-volume.tsx index 8d5bbaca..a6bb98c2 100644 --- a/app/client/modules/volumes/routes/create-volume.tsx +++ b/app/client/modules/volumes/routes/create-volume.tsx @@ -1,31 +1,16 @@ import { useMutation } from "@tanstack/react-query"; import { HardDrive, Plus } from "lucide-react"; import { useId } from "react"; -import { useNavigate } from "react-router"; import { toast } from "sonner"; import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { parseError } from "~/client/lib/errors"; -import type { Route } from "./+types/create-volume"; import { Alert, AlertDescription } from "~/client/components/ui/alert"; +import { useNavigate } from "@tanstack/react-router"; -export const handle = { - breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }], -}; - -export function meta(_: Route.MetaArgs) { - return [ - { title: "Zerobyte - Create Volume" }, - { - name: "description", - content: "Create a new storage volume with automatic mounting and health checks.", - }, - ]; -} - -export default function CreateVolume() { +export function CreateVolumePage() { const navigate = useNavigate(); const formId = useId(); @@ -33,7 +18,7 @@ export default function CreateVolume() { ...createVolumeMutation(), onSuccess: (data) => { toast.success("Volume created successfully"); - void navigate(`/volumes/${data.shortId}`); + void navigate({ to: `/volumes/${data.shortId}` }); }, }); @@ -69,7 +54,7 @@ export default function CreateVolume() { )}
-
- setSearchParams({ tab: value })} className="mt-4"> + navigate({ to: ".", search: () => ({ tab: value }) })} + className="mt-4" + > Configuration Files diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index 1f4a2541..e6a6aeef 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -1,7 +1,6 @@ -import { useQuery } from "@tanstack/react-query"; +import { useSuspenseQuery } from "@tanstack/react-query"; import { HardDrive, Plus, RotateCcw } from "lucide-react"; import { useState } from "react"; -import { useNavigate } from "react-router"; import { EmptyState } from "~/client/components/empty-state"; import { StatusDot } from "~/client/components/status-dot"; import { Button } from "~/client/components/ui/button"; @@ -10,10 +9,9 @@ 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 { VolumeIcon } from "~/client/components/volume-icon"; -import type { Route } from "./+types/volumes"; -import { listVolumes } from "~/client/api-client"; import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import type { VolumeStatus } from "~/client/lib/types"; +import { useNavigate } from "@tanstack/react-router"; const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => { const statusMap = { @@ -25,27 +23,7 @@ const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | " return statusMap[status]; }; -export const handle = { - breadcrumb: () => [{ label: "Volumes" }], -}; - -export function meta(_: Route.MetaArgs) { - return [ - { title: "Zerobyte - Volumes" }, - { - name: "description", - content: "Create, manage, monitor, and automate your Docker volumes with ease.", - }, - ]; -} - -export const clientLoader = async () => { - const volumes = await listVolumes(); - if (volumes.data) return volumes.data; - return []; -}; - -export default function Volumes({ loaderData }: Route.ComponentProps) { +export function VolumesPage() { const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [backendFilter, setBackendFilter] = useState(""); @@ -58,9 +36,8 @@ export default function Volumes({ loaderData }: Route.ComponentProps) { const navigate = useNavigate(); - const { data } = useQuery({ + const { data } = useSuspenseQuery({ ...listVolumesOptions(), - initialData: loaderData, }); const filteredVolumes = @@ -81,7 +58,7 @@ export default function Volumes({ loaderData }: Route.ComponentProps) { title="No volume" description="Manage and monitor all your storage backends in one place with advanced features like automatic mounting and health checks." button={ - @@ -95,13 +72,13 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
setSearchQuery(e.target.value)} />