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)} />