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
This commit is contained in:
parent
b45d36e06a
commit
825d46c934
90 changed files with 2496 additions and 1573 deletions
|
|
@ -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/**
|
||||
|
|
|
|||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
|
|
@ -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()
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -36,3 +36,4 @@ playwright/temp
|
|||
|
||||
# OpenAPI error logs
|
||||
openapi-ts-error-*.log
|
||||
.output
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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<T extends ClientOptions = ClientOptions2> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }));
|
||||
const fallbackClient = createClient(createConfig<ClientOptions2>({ 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof useMatches>[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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarMenuButton asChild>
|
||||
<NavLink to={item.url}>
|
||||
<Link to={item.url}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon className={cn({ "text-strong-accent": isActive })} />
|
||||
<span className={cn({ "text-strong-accent": isActive })}>{item.title}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={cn({ hidden: state !== "collapsed" })}>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(returnPath)}>
|
||||
<Button variant="outline" onClick={() => navigate({ to: returnPath })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>
|
||||
|
|
|
|||
|
|
@ -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,8 @@ 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/$scheduleId` : "."}
|
||||
params={backup ? { scheduleId: String(backup.id) } : {}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:underline"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ClientOnly } from "@tanstack/react-router";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildr
|
|||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="relative group">
|
||||
<ClientOnly>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
|
|
@ -28,6 +30,7 @@ export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildr
|
|||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground rotate-90" />
|
||||
</div>
|
||||
</ClientOnly>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { createAuthClient } from "better-auth/react";
|
||||
import { twoFactorClient, usernameClient, adminClient, organizationClient } from "better-auth/client/plugins";
|
||||
import { inferAdditionalFields } from "better-auth/client/plugins";
|
||||
import {
|
||||
twoFactorClient,
|
||||
usernameClient,
|
||||
adminClient,
|
||||
organizationClient,
|
||||
inferAdditionalFields,
|
||||
} from "better-auth/client/plugins";
|
||||
import type { auth } from "~/server/lib/auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type {
|
||||
GetBackupScheduleResponse,
|
||||
GetRepositoryResponse,
|
||||
GetScheduleMirrorsResponse,
|
||||
GetScheduleNotificationsResponse,
|
||||
GetVolumeResponse,
|
||||
ListNotificationDestinationsResponse,
|
||||
ListSnapshotsResponse,
|
||||
|
|
@ -17,3 +19,6 @@ export type BackupSchedule = GetBackupScheduleResponse;
|
|||
export type Snapshot = ListSnapshotsResponse[number];
|
||||
|
||||
export type NotificationDestination = ListNotificationDestinationsResponse[number];
|
||||
|
||||
export type ScheduleNotification = GetScheduleNotificationsResponse[number];
|
||||
export type ScheduleMirror = GetScheduleMirrorsResponse[number];
|
||||
|
|
|
|||
|
|
@ -1,30 +1,16 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/download-recovery-key";
|
||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Download Recovery Key" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Download your backup recovery key to ensure you can restore your data.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function DownloadRecoveryKeyPage() {
|
||||
export function DownloadRecoveryKeyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
|
|
@ -42,14 +28,14 @@ export default function DownloadRecoveryKeyPage() {
|
|||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Recovery key downloaded successfully!");
|
||||
void navigate("/volumes", { replace: true });
|
||||
void navigate({ to: "/volumes", replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to download recovery key", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { arktypeResolver } from "@hookform/resolvers/arktype";
|
|||
import { type } from "arktype";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
|
|
@ -11,21 +10,8 @@ import { Input } from "~/client/components/ui/input";
|
|||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||
import type { Route } from "./+types/login";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Login" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Sign in to your Zerobyte account.",
|
||||
},
|
||||
];
|
||||
}
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
const loginSchema = type({
|
||||
username: "2<=string<=50",
|
||||
|
|
@ -34,7 +20,7 @@ const loginSchema = type({
|
|||
|
||||
type LoginFormValues = typeof loginSchema.inferIn;
|
||||
|
||||
export default function LoginPage() {
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
|
|
@ -78,9 +64,9 @@ export default function LoginPage() {
|
|||
|
||||
const d = await authClient.getSession();
|
||||
if (data.user && !d.data?.user.hasDownloadedResticPassword) {
|
||||
void navigate("/download-recovery-key");
|
||||
void navigate({ to: "/download-recovery-key" });
|
||||
} else {
|
||||
void navigate("/volumes");
|
||||
void navigate({ to: "/volumes" });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -114,9 +100,9 @@ export default function LoginPage() {
|
|||
toast.success("Login successful");
|
||||
const session = await authClient.getSession();
|
||||
if (session.data?.user && !session.data.user.hasDownloadedResticPassword) {
|
||||
void navigate("/download-recovery-key");
|
||||
void navigate({ to: "/download-recovery-key" });
|
||||
} else {
|
||||
void navigate("/volumes");
|
||||
void navigate({ to: "/volumes" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -13,25 +12,15 @@ import {
|
|||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/onboarding";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Onboarding" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Welcome to Zerobyte. Create your admin account to get started.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const onboardingSchema = type({
|
||||
username: type("2<=string<=30").pipe((str) => str.trim().toLowerCase()),
|
||||
email: type("string.email").pipe((str) => str.trim().toLowerCase()),
|
||||
|
|
@ -41,7 +30,7 @@ const onboardingSchema = type({
|
|||
|
||||
type OnboardingFormValues = typeof onboardingSchema.inferIn;
|
||||
|
||||
export default function OnboardingPage() {
|
||||
export function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
|
|
@ -83,7 +72,7 @@ export default function OnboardingPage() {
|
|||
|
||||
if (data?.token) {
|
||||
toast.success("Admin user created successfully!");
|
||||
void navigate("/download-recovery-key");
|
||||
void navigate({ to: "/download-recovery-key" });
|
||||
} else if (error) {
|
||||
console.error(error);
|
||||
const errorMessage = error.message ?? "Unknown error";
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { CalendarClock, Database, HardDrive } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { BackupSchedule } from "~/client/lib/types";
|
||||
import { BackupStatusDot } from "./backup-status-dot";
|
||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||
return (
|
||||
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
|
||||
<Link key={schedule.id} to="/backups/$scheduleId" params={{ scheduleId: schedule.id.toString() }}>
|
||||
<Card key={schedule.id} className="flex flex-col h-full">
|
||||
<CardHeader className="pb-3 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Copy, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -19,9 +19,9 @@ import type { Repository } from "~/client/lib/types";
|
|||
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import type { GetScheduleMirrorsResponse } from "~/client/api-client";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
type Props = {
|
||||
scheduleId: number;
|
||||
|
|
@ -38,16 +38,35 @@ type MirrorAssignment = {
|
|||
lastCopyError: string | null;
|
||||
};
|
||||
|
||||
const buildAssignments = (mirrors: GetScheduleMirrorsResponse) => {
|
||||
const map = new Map<string, MirrorAssignment>();
|
||||
for (const mirror of mirrors) {
|
||||
map.set(mirror.repositoryId, {
|
||||
repositoryId: mirror.repositoryId,
|
||||
enabled: mirror.enabled,
|
||||
lastCopyAt: mirror.lastCopyAt,
|
||||
lastCopyStatus: mirror.lastCopyStatus,
|
||||
lastCopyError: mirror.lastCopyError,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, repositories, initialData }: Props) => {
|
||||
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(new Map());
|
||||
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(() => buildAssignments(initialData));
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
|
||||
const { data: currentMirrors } = useQuery({
|
||||
const { data: currentMirrors } = useSuspenseQuery({
|
||||
...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||
initialData,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasChanges) {
|
||||
setAssignments(buildAssignments(currentMirrors));
|
||||
}
|
||||
}, [currentMirrors, hasChanges]);
|
||||
|
||||
const { data: compatibility } = useQuery({
|
||||
...getMirrorCompatibilityOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||
});
|
||||
|
|
@ -75,23 +94,6 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
return map;
|
||||
}, [compatibility]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMirrors && !hasChanges) {
|
||||
const map = new Map<string, MirrorAssignment>();
|
||||
for (const mirror of currentMirrors) {
|
||||
map.set(mirror.repositoryId, {
|
||||
repositoryId: mirror.repositoryId,
|
||||
enabled: mirror.enabled,
|
||||
lastCopyAt: mirror.lastCopyAt,
|
||||
lastCopyStatus: mirror.lastCopyStatus,
|
||||
lastCopyError: mirror.lastCopyError,
|
||||
});
|
||||
}
|
||||
|
||||
setAssignments(map);
|
||||
}
|
||||
}, [currentMirrors, hasChanges]);
|
||||
|
||||
const addRepository = (repositoryId: string) => {
|
||||
const newAssignments = new Map(assignments);
|
||||
newAssignments.set(repositoryId, {
|
||||
|
|
@ -288,7 +290,8 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/repositories/${repository.shortId}`}
|
||||
to="/repositories/$repositoryId"
|
||||
params={{ repositoryId: repository.id }}
|
||||
className="hover:underline flex items-center gap-2"
|
||||
>
|
||||
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Bell, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
|
|
@ -31,7 +31,18 @@ type NotificationAssignment = {
|
|||
};
|
||||
|
||||
export const ScheduleNotificationsConfig = ({ scheduleId, destinations, initialData }: Props) => {
|
||||
const [assignments, setAssignments] = useState<Map<number, NotificationAssignment>>(new Map());
|
||||
const map = new Map<number, NotificationAssignment>();
|
||||
for (const assignment of initialData) {
|
||||
map.set(assignment.destinationId, {
|
||||
destinationId: assignment.destinationId,
|
||||
notifyOnStart: assignment.notifyOnStart,
|
||||
notifyOnSuccess: assignment.notifyOnSuccess,
|
||||
notifyOnWarning: assignment.notifyOnWarning,
|
||||
notifyOnFailure: assignment.notifyOnFailure,
|
||||
});
|
||||
}
|
||||
|
||||
const [assignments, setAssignments] = useState<Map<number, NotificationAssignment>>(map);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
|
||||
|
|
@ -53,23 +64,6 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations, initialD
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentAssignments) {
|
||||
const map = new Map<number, NotificationAssignment>();
|
||||
for (const assignment of currentAssignments) {
|
||||
map.set(assignment.destinationId, {
|
||||
destinationId: assignment.destinationId,
|
||||
notifyOnStart: assignment.notifyOnStart,
|
||||
notifyOnSuccess: assignment.notifyOnSuccess,
|
||||
notifyOnWarning: assignment.notifyOnWarning,
|
||||
notifyOnFailure: assignment.notifyOnFailure,
|
||||
});
|
||||
}
|
||||
|
||||
setAssignments(map);
|
||||
}
|
||||
}, [currentAssignments]);
|
||||
|
||||
const addDestination = (destinationId: string) => {
|
||||
const id = Number.parseInt(destinationId, 10);
|
||||
const newAssignments = new Map(assignments);
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { handleRepositoryError } from "~/client/lib/errors";
|
||||
import { Link } from "react-router";
|
||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
type Props = {
|
||||
schedule: BackupSchedule;
|
||||
|
|
@ -93,12 +93,20 @@ export const ScheduleSummary = (props: Props) => {
|
|||
<div>
|
||||
<CardTitle>{schedule.name}</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
<Link to={`/volumes/${schedule.volume.shortId}`} className="hover:underline">
|
||||
<Link
|
||||
to="/volumes/$volumeId"
|
||||
className="hover:underline"
|
||||
params={{ volumeId: schedule.volume.shortId }}
|
||||
>
|
||||
<HardDrive className="inline h-4 w-4 mr-2" />
|
||||
<span>{schedule.volume.name}</span>
|
||||
</Link>
|
||||
<span className="mx-2">→</span>
|
||||
<Link to={`/repositories/${schedule.repository.shortId}`} className="hover:underline">
|
||||
<Link
|
||||
to="/repositories/$repositoryId"
|
||||
className="hover:underline"
|
||||
params={{ repositoryId: schedule.repository.shortId }}
|
||||
>
|
||||
<Database className="inline h-4 w-4 mr-2 text-strong-accent" />
|
||||
<span className="text-strong-accent">{schedule.repository.name}</span>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -95,10 +95,11 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={
|
||||
to={backupId ? "/backups/$backupId/$snapshotId/restore" : "/repositories/$repoId/$snapId/restore"}
|
||||
params={
|
||||
backupId
|
||||
? `/backups/${backupId}/${snapshot.short_id}/restore`
|
||||
: `/repositories/${repositoryId}/${snapshot.short_id}/restore`
|
||||
? { backupId, snapshotId: snapshot.short_id }
|
||||
: { repoId: repositoryId, snapId: snapshot.short_id }
|
||||
}
|
||||
className={buttonVariants({ variant: "primary", size: "sm" })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useId, useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { redirect, useNavigate } from "react-router";
|
||||
import { useQuery, useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Save, X } from "lucide-react";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
|
|
@ -27,58 +26,34 @@ import { parseError, handleRepositoryError } from "~/client/lib/errors";
|
|||
import { getCronExpression } from "~/utils/utils";
|
||||
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
||||
import { ScheduleSummary } from "../components/schedule-summary";
|
||||
import type { Route } from "./+types/backup-details";
|
||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||
import {
|
||||
getBackupSchedule,
|
||||
getScheduleMirrors,
|
||||
getScheduleNotifications,
|
||||
listNotificationDestinations,
|
||||
listRepositories,
|
||||
} from "~/client/api-client";
|
||||
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
|
||||
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import type {
|
||||
BackupSchedule,
|
||||
NotificationDestination,
|
||||
Repository,
|
||||
ScheduleMirror,
|
||||
ScheduleNotification,
|
||||
} from "~/client/lib/types";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => {
|
||||
const data = match.loaderData;
|
||||
return [{ label: "Backups", href: "/backups" }, { label: data.schedule.name }];
|
||||
},
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Backup Job Details" },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||
const [schedule, notifs, repos, scheduleNotifs, mirrors] = await Promise.all([
|
||||
getBackupSchedule({ path: { scheduleId: params.id } }),
|
||||
listNotificationDestinations(),
|
||||
listRepositories(),
|
||||
getScheduleNotifications({ path: { scheduleId: params.id } }),
|
||||
getScheduleMirrors({ path: { scheduleId: params.id } }),
|
||||
]);
|
||||
|
||||
if (!schedule.data) return redirect("/backups");
|
||||
|
||||
return {
|
||||
schedule: schedule.data,
|
||||
notifs: notifs.data,
|
||||
repos: repos.data,
|
||||
scheduleNotifs: scheduleNotifs.data,
|
||||
scheduleMirrors: mirrors.data,
|
||||
type Props = {
|
||||
loaderData: {
|
||||
schedule: BackupSchedule;
|
||||
notifs: NotificationDestination[];
|
||||
repos: Repository[];
|
||||
scheduleNotifs: ScheduleNotification[];
|
||||
mirrors: ScheduleMirror[];
|
||||
};
|
||||
scheduleId: string;
|
||||
};
|
||||
|
||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||
export function ScheduleDetailsPage(props: Props) {
|
||||
const { loaderData, scheduleId } = props;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const formId = useId();
|
||||
|
|
@ -86,9 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: schedule } = useQuery({
|
||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||
initialData: loaderData.schedule,
|
||||
const { data: schedule } = useSuspenseQuery({
|
||||
...getBackupScheduleOptions({ path: { scheduleId } }),
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
@ -136,7 +110,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||
...deleteBackupScheduleMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup schedule deleted successfully");
|
||||
void navigate("/backups");
|
||||
void navigate({ to: "/backups" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
|
||||
|
|
@ -267,7 +241,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||
scheduleId={schedule.id}
|
||||
primaryRepositoryId={schedule.repositoryId}
|
||||
repositories={loaderData.repos ?? []}
|
||||
initialData={loaderData.scheduleMirrors ?? []}
|
||||
initialData={loaderData.mirrors ?? []}
|
||||
/>
|
||||
</div>
|
||||
<SnapshotTimeline
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
|
|
@ -10,44 +10,21 @@ import {
|
|||
} from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
import type { Route } from "./+types/backups";
|
||||
import { listBackupSchedules } from "~/client/api-client";
|
||||
import {
|
||||
listBackupSchedulesOptions,
|
||||
reorderBackupSchedulesMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { SortableCard } from "~/client/components/sortable-card";
|
||||
import { BackupCard } from "../components/backup-card";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Backups" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Backup Jobs" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const jobs = await listBackupSchedules();
|
||||
if (jobs.data) return jobs.data;
|
||||
return [];
|
||||
};
|
||||
|
||||
export default function Backups({ loaderData }: Route.ComponentProps) {
|
||||
const { data: schedules, isLoading } = useQuery({
|
||||
export function BackupsPage() {
|
||||
const { data: schedules } = useSuspenseQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
initialData: loaderData,
|
||||
});
|
||||
|
||||
const [items, setItems] = useState(schedules?.map((s) => s.id) ?? []);
|
||||
|
|
@ -85,14 +62,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
|||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">Loading backup schedules...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schedules || schedules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useId, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Database, HardDrive, Plus } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createBackupScheduleMutation,
|
||||
|
|
@ -15,50 +14,26 @@ import { parseError } from "~/client/lib/errors";
|
|||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { getCronExpression } from "~/utils/utils";
|
||||
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
||||
import type { Route } from "./+types/create-backup";
|
||||
import { listRepositories, listVolumes } from "~/client/api-client";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Backups", href: "/backups" }, { label: "Create" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Backup Job" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new automated backup job for your volumes.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const [volumes, repositories] = await Promise.all([listVolumes(), listRepositories()]);
|
||||
|
||||
if (volumes.data && repositories.data) return { volumes: volumes.data, repositories: repositories.data };
|
||||
return { volumes: [], repositories: [] };
|
||||
};
|
||||
|
||||
export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
||||
export function CreateBackupPage() {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
const [selectedVolumeId, setSelectedVolumeId] = useState<number | undefined>();
|
||||
|
||||
const { data: volumesData, isLoading: loadingVolumes } = useQuery({
|
||||
const { data: volumesData } = useSuspenseQuery({
|
||||
...listVolumesOptions(),
|
||||
initialData: loaderData.volumes,
|
||||
});
|
||||
|
||||
const { data: repositoriesData } = useQuery({
|
||||
const { data: repositoriesData } = useSuspenseQuery({
|
||||
...listRepositoriesOptions(),
|
||||
initialData: loaderData.repositories,
|
||||
});
|
||||
|
||||
const createSchedule = useMutation({
|
||||
...createBackupScheduleMutation(),
|
||||
onSuccess: (data) => {
|
||||
toast.success("Backup job created successfully");
|
||||
void navigate(`/backups/${data.id}`);
|
||||
void navigate({ to: `/backups/${data.id}` });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create backup job", {
|
||||
|
|
@ -104,14 +79,6 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
const selectedVolume = volumesData.find((v) => v.id === selectedVolumeId);
|
||||
|
||||
if (loadingVolumes) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!volumesData.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getBackupSchedule, getRepository, getSnapshotDetails } from "~/client/api-client";
|
||||
import { RestoreForm } from "~/client/components/restore-form";
|
||||
import type { Route } from "./+types/restore-snapshot";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Backups", href: "/backups" },
|
||||
{ label: `Schedule #${match.params.id}`, href: `/backups/${match.params.id}` },
|
||||
{ label: match.params.snapshotId },
|
||||
{ label: "Restore" },
|
||||
],
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Restore files from a backup snapshot.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||
|
||||
if (!schedule.data) return redirect("/backups");
|
||||
|
||||
const [snapshot, repository] = await Promise.all([
|
||||
getSnapshotDetails({
|
||||
path: {
|
||||
id: schedule.data.repositoryId,
|
||||
snapshotId: params.snapshotId,
|
||||
},
|
||||
}),
|
||||
getRepository({ path: { id: schedule.data.repositoryId } }),
|
||||
]);
|
||||
|
||||
if (!snapshot.data) return redirect(`/backups/${params.id}`);
|
||||
if (!repository.data) return redirect(`/backups/${params.id}`);
|
||||
|
||||
return {
|
||||
snapshot: snapshot.data,
|
||||
repository: repository.data,
|
||||
snapshotId: params.snapshotId,
|
||||
backupId: params.id,
|
||||
};
|
||||
};
|
||||
|
||||
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
|
||||
const { snapshot, repository, snapshotId, backupId } = loaderData;
|
||||
|
||||
return (
|
||||
<RestoreForm
|
||||
snapshot={snapshot}
|
||||
repository={repository}
|
||||
snapshotId={snapshotId}
|
||||
returnPath={`/backups/${backupId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +1,16 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Bell, Plus } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { createNotificationDestinationMutation } 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 { parseError } from "~/client/lib/errors";
|
||||
import type { Route } from "./+types/create-notification";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Notification" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new notification destination for backup alerts.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function CreateNotification() {
|
||||
export function CreateNotificationPage() {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
|
||||
|
|
@ -33,7 +18,7 @@ export default function CreateNotification() {
|
|||
...createNotificationDestinationMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Notification destination created successfully");
|
||||
void navigate(`/notifications`);
|
||||
void navigate({ to: `/notifications` });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -64,7 +49,7 @@ export default function CreateNotification() {
|
|||
)}
|
||||
<CreateNotificationForm mode="create" formId={formId} onSubmit={handleSubmit} />
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate({ to: "/notifications" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} loading={createNotification.isPending}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { redirect, useNavigate } from "react-router";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useId } from "react";
|
||||
import {
|
||||
|
|
@ -20,53 +19,27 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { getNotificationDestination } from "~/client/api-client/sdk.gen";
|
||||
import type { Route } from "./+types/notification-details";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Bell, Save, TestTube2, Trash2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Notifications", href: "/notifications" },
|
||||
{ label: match.params.id },
|
||||
],
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - Notification ${params.id}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and edit notification destination settings.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const destination = await getNotificationDestination({ path: { id: params.id ?? "" } });
|
||||
if (destination.data) return destination.data;
|
||||
|
||||
return redirect("/notifications");
|
||||
};
|
||||
|
||||
export default function NotificationDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
export function NotificationDetailsPage({ notificationId }: { notificationId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
...getNotificationDestinationOptions({ path: { id: String(loaderData.id) } }),
|
||||
initialData: loaderData,
|
||||
const { data } = useSuspenseQuery({
|
||||
...getNotificationDestinationOptions({ path: { id: notificationId } }),
|
||||
});
|
||||
|
||||
const deleteDestination = useMutation({
|
||||
...deleteNotificationDestinationMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Notification destination deleted successfully");
|
||||
void navigate("/notifications");
|
||||
void navigate({ to: "/notifications" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete notification destination", {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Bell, 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";
|
||||
|
|
@ -9,31 +8,10 @@ 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/notifications";
|
||||
import { listNotificationDestinations } from "~/client/api-client";
|
||||
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Notifications" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Notifications" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage notification destinations for backup alerts.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const result = await listNotificationDestinations();
|
||||
if (result.data) return result.data;
|
||||
return [];
|
||||
};
|
||||
|
||||
export default function Notifications({ loaderData }: Route.ComponentProps) {
|
||||
export function NotificationsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
|
@ -46,19 +24,16 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data } = useQuery({
|
||||
const { data } = useSuspenseQuery({
|
||||
...listNotificationDestinationsOptions(),
|
||||
initialData: loaderData,
|
||||
});
|
||||
|
||||
const filteredNotifications =
|
||||
data?.filter((notification) => {
|
||||
const filteredNotifications = data.filter((notification) => {
|
||||
const matchesSearch = notification.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = !typeFilter || notification.type === typeFilter;
|
||||
const matchesStatus =
|
||||
!statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled);
|
||||
const matchesStatus = !statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled);
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
}) || [];
|
||||
});
|
||||
|
||||
const hasNoNotifications = data.length === 0;
|
||||
const hasNoFilteredNotifications = filteredNotifications.length === 0 && !hasNoNotifications;
|
||||
|
|
@ -70,7 +45,7 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
title="No notification destinations"
|
||||
description="Set up notification channels to receive alerts when your backups complete or fail."
|
||||
button={
|
||||
<Button onClick={() => navigate("/notifications/create")}>
|
||||
<Button onClick={() => navigate({ to: "/notifications/create" })}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Destination
|
||||
</Button>
|
||||
|
|
@ -84,13 +59,13 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
className="w-full lg:w-45 min-w-45 -mr-px -mt-px"
|
||||
placeholder="Search destinations…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||
<SelectTrigger className="w-full lg:w-45 min-w-45 -mr-px -mt-px">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -105,7 +80,7 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||
<SelectTrigger className="w-full lg:w-45 min-w-45 -mt-px">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -120,7 +95,7 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => navigate("/notifications/create")}>
|
||||
<Button onClick={() => navigate({ to: "/notifications/create" })}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Destination
|
||||
</Button>
|
||||
|
|
@ -129,7 +104,7 @@ export default function Notifications({ 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">Type</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -152,7 +127,7 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||
<TableRow
|
||||
key={notification.id}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/notifications/${notification.id}`)}
|
||||
onClick={() => navigate({ to: `/notifications/${notification.id}` })}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
|
||||
<TableCell className="capitalize">{notification.type}</TableCell>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Database, Plus } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
|
|
@ -11,24 +10,10 @@ import {
|
|||
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-repository";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Repositories", href: "/repositories" }, { label: "Create" }],
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Create Repository" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new backup repository with encryption and compression.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function CreateRepository() {
|
||||
export function CreateRepositoryPage() {
|
||||
const navigate = useNavigate();
|
||||
const formId = useId();
|
||||
|
||||
|
|
@ -36,7 +21,7 @@ export default function CreateRepository() {
|
|||
...createRepositoryMutation(),
|
||||
onSuccess: (data) => {
|
||||
toast.success("Repository created successfully");
|
||||
void navigate(`/repositories/${data.repository.shortId}`);
|
||||
void navigate({ to: `/repositories/${data.repository.shortId}` });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -78,7 +63,7 @@ export default function CreateRepository() {
|
|||
loading={createRepository.isPending}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/repositories")}>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate({ to: "/repositories" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} loading={createRepository.isPending}>
|
||||
|
|
|
|||
|
|
@ -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,11 @@ 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 +25,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 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 +46,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 +93,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 +102,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 +126,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>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,20 @@
|
|||
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 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 +23,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 +32,9 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||
<RepositoryInfoTabContent repository={data} />
|
||||
</TabsContent>
|
||||
<TabsContent value="snapshots">
|
||||
<Suspense>
|
||||
<RepositorySnapshotsTabContent repository={data} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,48 +1,15 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getRepository, getSnapshotDetails } from "~/client/api-client";
|
||||
import { RestoreForm } from "~/client/components/restore-form";
|
||||
import type { Route } from "./+types/restore-snapshot";
|
||||
import type { Repository, Snapshot } from "~/client/lib/types";
|
||||
|
||||
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, href: `/repositories/${match.params.id}/${match.params.snapshotId}` },
|
||||
{ label: "Restore" },
|
||||
],
|
||||
type Props = {
|
||||
snapshot: Snapshot;
|
||||
repository: Repository;
|
||||
snapshotId: string;
|
||||
returnPath: string;
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "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 (!snapshot.data) return redirect("/repositories");
|
||||
if (!repository.data) return redirect(`/repositories`);
|
||||
|
||||
return { snapshot: snapshot.data, id: params.id, repository: repository.data, snapshotId: params.snapshotId };
|
||||
};
|
||||
|
||||
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
|
||||
const { snapshot, id, snapshotId, repository } = loaderData;
|
||||
|
||||
return (
|
||||
<RestoreForm
|
||||
snapshot={snapshot}
|
||||
repository={repository}
|
||||
snapshotId={snapshotId}
|
||||
returnPath={`/repositories/${id}/${snapshotId}`}
|
||||
/>
|
||||
);
|
||||
export function RestoreSnapshotPage(props: Props) {
|
||||
const { snapshot, returnPath, snapshotId, repository } = props;
|
||||
|
||||
return <RestoreForm snapshot={snapshot} repository={repository} snapshotId={snapshotId} returnPath={returnPath} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,126 +1,105 @@
|
|||
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 SnapshotError = () => {
|
||||
const { repoId } = useParams({ from: "/(dashboard)/repositories/$repoId/$snapshotId" });
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { id, snapshotId } = useParams<{
|
||||
id: 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 schedules = useQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
});
|
||||
|
||||
if (!id || !snapshotId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-destructive">Invalid snapshot reference</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loaderData.snapshotError) {
|
||||
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-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/${id}?tab=snapshots`}>
|
||||
<Link
|
||||
to={`/repositories/$repositoryId`}
|
||||
search={() => ({ tab: "snapshots" })}
|
||||
params={{ repositoryId: repoId }}
|
||||
>
|
||||
<Button variant="outline">Back to repository</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
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 function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) {
|
||||
const [showAllPaths, setShowAllPaths] = useState(false);
|
||||
|
||||
const { data: repository } = useSuspenseQuery({
|
||||
...getRepositoryOptions({ path: { id: repositoryId } }),
|
||||
});
|
||||
|
||||
const { data: schedules } = useSuspenseQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
});
|
||||
|
||||
const { data, error } = useQuery({
|
||||
...getSnapshotDetailsOptions({ path: { id: repositoryId, snapshotId: snapshotId } }),
|
||||
});
|
||||
const backupSchedule = schedules?.find((s) => data?.tags.includes(s.shortId));
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<SnapshotError />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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,33 +108,26 @@ 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>
|
||||
</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 (
|
||||
{backupSchedule && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Backup Schedule:</span>
|
||||
<p>
|
||||
<Link to={`/backups/${backupSchedule?.id}`} className="text-primary hover:underline">
|
||||
<Link
|
||||
to="/backups/$scheduleId"
|
||||
className="text-primary hover:underline"
|
||||
params={{ scheduleId: backupSchedule.shortId }}
|
||||
>
|
||||
{backupSchedule?.name}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
@ -164,34 +136,32 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||
<span className="text-muted-foreground">Volume:</span>
|
||||
<p>
|
||||
<Link
|
||||
to={`/volumes/${backupSchedule?.volume.shortId}`}
|
||||
to={`/volumes/$volumeId`}
|
||||
className="text-primary hover:underline"
|
||||
params={{ volumeId: backupSchedule.volume.shortId }}
|
||||
>
|
||||
{backupSchedule?.volume.name}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Download, KeyRound, User, X, Users, Settings as SettingsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
downloadResticPasswordMutation,
|
||||
|
|
@ -24,31 +23,16 @@ import { Label } from "~/client/components/ui/label";
|
|||
import { Switch } from "~/client/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { appContext } from "~/context";
|
||||
import { type AppContext } from "~/context";
|
||||
import { TwoFactorSection } from "../components/two-factor-section";
|
||||
import { UserManagement } from "../components/user-management";
|
||||
import type { Route } from "./+types/settings";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: () => [{ label: "Settings" }],
|
||||
type Props = {
|
||||
appContext: AppContext;
|
||||
};
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Zerobyte - Settings" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your account settings and preferences.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||
const ctx = context.get(appContext);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
export function SettingsPage({ appContext }: Props) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
|
@ -56,11 +40,11 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
const [downloadPassword, setDownloadPassword] = useState("");
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "account";
|
||||
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
|
||||
const activeTab = tab || "account";
|
||||
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = loaderData.user?.role === "admin";
|
||||
const isAdmin = appContext.user?.role === "admin";
|
||||
|
||||
const registrationStatusQuery = useQuery({
|
||||
...getRegistrationStatusOptions(),
|
||||
|
|
@ -84,7 +68,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
void navigate("/login", { replace: true });
|
||||
void navigate({ to: "/login", replace: true });
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
console.error(error);
|
||||
|
|
@ -118,7 +102,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
},
|
||||
});
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
const handleChangePassword = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
|
@ -157,7 +141,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const handleDownloadResticPassword = (e: React.FormEvent) => {
|
||||
const handleDownloadResticPassword = (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!downloadPassword) {
|
||||
|
|
@ -173,7 +157,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
};
|
||||
|
||||
const onTabChange = (value: string) => {
|
||||
setSearchParams({ tab: value });
|
||||
void navigate({ to: ".", search: () => ({ tab: value }) });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -198,7 +182,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
<CardContent className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
|
||||
<Input value={appContext.user?.username || ""} disabled className="max-w-md" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
|
|
@ -318,7 +302,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
</Dialog>
|
||||
</CardContent>
|
||||
|
||||
<TwoFactorSection twoFactorEnabled={loaderData.user?.twoFactorEnabled} />
|
||||
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
<CreateVolumeForm mode="create" formId={formId} onSubmit={handleSubmit} loading={createVolume.isPending} />
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/volumes")}>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate({ to: "/volumes" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} loading={createVolume.isPending}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { Plug, Unplug } from "lucide-react";
|
||||
|
|
@ -18,10 +19,8 @@ import {
|
|||
import { VolumeIcon } from "~/client/components/volume-icon";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import type { Route } from "./+types/volume-details";
|
||||
import { VolumeInfoTabContent } from "../tabs/info";
|
||||
import { FilesTabContent } from "../tabs/files";
|
||||
import { getVolume } from "~/client/api-client";
|
||||
import type { VolumeStatus } from "~/client/lib/types";
|
||||
import {
|
||||
deleteVolumeMutation,
|
||||
|
|
@ -29,6 +28,7 @@ import {
|
|||
mountVolumeMutation,
|
||||
unmountVolumeMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
|
||||
const statusMap = {
|
||||
|
|
@ -40,42 +40,23 @@ const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "
|
|||
return statusMap[status];
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.id }],
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - ${params.id}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const volume = await getVolume({ path: { id: params.id } });
|
||||
if (volume.data) return volume.data;
|
||||
};
|
||||
|
||||
export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
export function VolumeDetails({ volumeId }: { volumeId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "info";
|
||||
const searchParams = useSearch({ from: "/(dashboard)/volumes/$volumeId" });
|
||||
|
||||
const activeTab = searchParams.tab || "info";
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
...getVolumeOptions({ path: { id: id ?? "" } }),
|
||||
initialData: loaderData,
|
||||
const { data } = useSuspenseQuery({
|
||||
...getVolumeOptions({ path: { id: volumeId } }),
|
||||
});
|
||||
|
||||
const deleteVol = useMutation({
|
||||
...deleteVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Volume deleted successfully");
|
||||
void navigate("/volumes");
|
||||
void navigate({ to: "/volumes" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete volume", {
|
||||
|
|
@ -110,10 +91,10 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteVol.mutate({ path: { id: id ?? "" } });
|
||||
deleteVol.mutate({ path: { id: volumeId } });
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
if (!volumeId) {
|
||||
return <div>Volume not found</div>;
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +120,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => mountVol.mutate({ path: { id } })}
|
||||
onClick={() => mountVol.mutate({ path: { id: volumeId } })}
|
||||
loading={mountVol.isPending}
|
||||
className={cn({ hidden: volume.status === "mounted" })}
|
||||
>
|
||||
|
|
@ -148,7 +129,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => unmountVol.mutate({ path: { id } })}
|
||||
onClick={() => unmountVol.mutate({ path: { id: volumeId } })}
|
||||
loading={unmountVol.isPending}
|
||||
className={cn({ hidden: volume.status !== "mounted" })}
|
||||
>
|
||||
|
|
@ -160,7 +141,11 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => navigate({ to: ".", search: () => ({ tab: value }) })}
|
||||
className="mt-4"
|
||||
>
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Button onClick={() => navigate({ to: "/volumes/create" })}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
|
|
@ -95,13 +72,13 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
|
||||
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
|
||||
<Input
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
className="w-full lg:w-45 min-w-45 -mr-px -mt-px"
|
||||
placeholder="Search volumes…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
|
||||
<SelectTrigger className="w-full lg:w-45 min-w-45 -mr-px -mt-px">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -111,7 +88,7 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={backendFilter} onValueChange={setBackendFilter}>
|
||||
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
|
||||
<SelectTrigger className="w-full lg:w-45 min-w-45 -mt-px">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -127,7 +104,7 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => navigate("/volumes/create")}>
|
||||
<Button onClick={() => navigate({ to: "/volumes/create" })}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Volume
|
||||
</Button>
|
||||
|
|
@ -136,7 +113,7 @@ export default function Volumes({ 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 text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -159,7 +136,7 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||
<TableRow
|
||||
key={volume.name}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/volumes/${volume.shortId}`)}
|
||||
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Check } from "lucide-react";
|
||||
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
||||
|
|
@ -20,6 +19,7 @@ import { HealthchecksCard } from "../components/healthchecks-card";
|
|||
import { StorageChart } from "../components/storage-chart";
|
||||
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { UpdateVolumeResponse } from "~/client/api-client/types.gen";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
type Props = {
|
||||
volume: Volume;
|
||||
|
|
@ -37,7 +37,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
setPendingValues(null);
|
||||
|
||||
if (data.name !== volume.name) {
|
||||
void navigate(`/volumes/${data.shortId}`);
|
||||
void navigate({ to: `/volumes/${data.shortId}` });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { redirect } from "react-router";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
return redirect("/volumes");
|
||||
};
|
||||
|
||||
export const loader = async () => {
|
||||
return redirect("/volumes");
|
||||
};
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { createContext } from "react-router";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
|
|
@ -9,12 +7,7 @@ type User = {
|
|||
role?: string | null | undefined;
|
||||
};
|
||||
|
||||
type AppContext = {
|
||||
export type AppContext = {
|
||||
user: User | null;
|
||||
hasUsers: boolean;
|
||||
};
|
||||
|
||||
export const appContext = createContext<AppContext>({
|
||||
user: null,
|
||||
hasUsers: false,
|
||||
});
|
||||
|
|
|
|||
61
app/lib/request-client.ts
Normal file
61
app/lib/request-client.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { Config } from "~/client/api-client/client";
|
||||
import { createClient, createConfig } from "~/client/api-client/client";
|
||||
|
||||
export type RequestClient = ReturnType<typeof createClient>;
|
||||
|
||||
type RequestClientStore = {
|
||||
getStore: () => RequestClient | undefined;
|
||||
run: <T>(client: RequestClient, fn: () => T) => T;
|
||||
};
|
||||
|
||||
type AsyncLocalStorageConstructor = new <T>() => {
|
||||
getStore: () => T | undefined;
|
||||
run: <R>(store: T, callback: () => R) => R;
|
||||
};
|
||||
|
||||
let requestClientStore: RequestClientStore | undefined;
|
||||
let requestClientStorePromise: Promise<RequestClientStore | undefined> | undefined;
|
||||
|
||||
const ASYNC_HOOKS_MODULE = "node:async_hooks";
|
||||
|
||||
const loadRequestClientStore = async (): Promise<RequestClientStore | undefined> => {
|
||||
if (typeof window !== "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!requestClientStorePromise) {
|
||||
requestClientStorePromise = (async () => {
|
||||
const asyncHooksModule = (await import(/* @vite-ignore */ ASYNC_HOOKS_MODULE)) as {
|
||||
AsyncLocalStorage: AsyncLocalStorageConstructor;
|
||||
};
|
||||
requestClientStore = new asyncHooksModule.AsyncLocalStorage<RequestClient>();
|
||||
return requestClientStore;
|
||||
})();
|
||||
}
|
||||
|
||||
return requestClientStorePromise;
|
||||
};
|
||||
|
||||
export function getRequestClient(): RequestClient {
|
||||
const client = requestClientStore?.getStore();
|
||||
|
||||
if (!client) {
|
||||
throw new Error("No request client available");
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function runWithRequestClient<T>(client: RequestClient, fn: () => T): Promise<T> {
|
||||
const store = await loadRequestClientStore();
|
||||
|
||||
if (!store) {
|
||||
return fn();
|
||||
}
|
||||
|
||||
return store.run(client, fn);
|
||||
}
|
||||
|
||||
export function createRequestClient(config: Config): RequestClient {
|
||||
return createClient(createConfig(config));
|
||||
}
|
||||
17
app/middleware/api-client.ts
Normal file
17
app/middleware/api-client.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import {
|
||||
createRequestClient,
|
||||
runWithRequestClient,
|
||||
} from "~/lib/request-client";
|
||||
|
||||
export const apiClientMiddleware = createMiddleware().server(async ({ next, request }) => {
|
||||
const client = createRequestClient({
|
||||
baseUrl: new URL(request.url).origin,
|
||||
headers: {
|
||||
cookie: getRequestHeaders().get("cookie") ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return runWithRequestClient(client, () => next());
|
||||
});
|
||||
|
|
@ -1,27 +1,29 @@
|
|||
import { redirect, type MiddlewareFunction } from "react-router";
|
||||
import { getStatus } from "~/client/api-client";
|
||||
import { authClient } from "~/client/lib/auth-client";
|
||||
import { appContext } from "~/context";
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { redirect } from "@tanstack/react-router";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { authService } from "~/server/modules/auth/auth.service";
|
||||
|
||||
export const authMiddleware: MiddlewareFunction = async ({ context, request }) => {
|
||||
const { data: session } = await authClient.getSession();
|
||||
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
|
||||
const headers = getRequestHeaders();
|
||||
const session = await auth.api.getSession({ headers });
|
||||
|
||||
const isAuthRoute = ["/login", "/onboarding"].includes(new URL(request.url).pathname);
|
||||
|
||||
if (!session?.user?.id && !isAuthRoute) {
|
||||
const status = await getStatus();
|
||||
if (!status.data?.hasUsers) {
|
||||
throw redirect("/onboarding");
|
||||
const hasUsers = await authService.hasUsers();
|
||||
if (!hasUsers) {
|
||||
throw redirect({ to: "/onboarding" });
|
||||
}
|
||||
|
||||
throw redirect("/login");
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
|
||||
if (session?.user?.id) {
|
||||
context.set(appContext, { user: session.user, hasUsers: true });
|
||||
|
||||
if (isAuthRoute) {
|
||||
throw redirect("/");
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return next();
|
||||
});
|
||||
|
|
|
|||
96
app/root.tsx
96
app/root.tsx
|
|
@ -1,96 +0,0 @@
|
|||
import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
import { Toaster } from "./client/components/ui/sonner";
|
||||
import { useServerEvents } from "./client/hooks/use-server-events";
|
||||
import { client } from "./client/api-client/client.gen";
|
||||
|
||||
client.setConfig({
|
||||
baseUrl: "/",
|
||||
});
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
mutationCache: new MutationCache({
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Mutation error:", error);
|
||||
void queryClient.invalidateQueries();
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/images/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Zerobyte" />
|
||||
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<body className="dark">
|
||||
{children}
|
||||
<Toaster />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</QueryClientProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useServerEvents();
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details = error.status === 404 ? "The requested page could not be found." : error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-4 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
534
app/routeTree.gen.ts
Normal file
534
app/routeTree.gen.ts
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as dashboardRouteRouteImport } from './routes/(dashboard)/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ApiSplatRouteImport } from './routes/api.$'
|
||||
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 dashboardSettingsIndexRouteImport } from './routes/(dashboard)/settings/index'
|
||||
import { Route as dashboardRepositoriesIndexRouteImport } from './routes/(dashboard)/repositories/index'
|
||||
import { Route as dashboardNotificationsIndexRouteImport } from './routes/(dashboard)/notifications/index'
|
||||
import { Route as dashboardBackupsIndexRouteImport } from './routes/(dashboard)/backups/index'
|
||||
import { Route as dashboardVolumesCreateRouteImport } from './routes/(dashboard)/volumes/create'
|
||||
import { Route as dashboardVolumesVolumeIdRouteImport } from './routes/(dashboard)/volumes/$volumeId'
|
||||
import { Route as dashboardRepositoriesCreateRouteImport } from './routes/(dashboard)/repositories/create'
|
||||
import { Route as dashboardRepositoriesRepositoryIdRouteImport } from './routes/(dashboard)/repositories/$repositoryId'
|
||||
import { Route as dashboardNotificationsCreateRouteImport } from './routes/(dashboard)/notifications/create'
|
||||
import { Route as dashboardNotificationsNotificationIdRouteImport } from './routes/(dashboard)/notifications/$notificationId'
|
||||
import { Route as dashboardBackupsCreateRouteImport } from './routes/(dashboard)/backups/create'
|
||||
import { Route as dashboardBackupsScheduleIdRouteImport } from './routes/(dashboard)/backups/$scheduleId'
|
||||
import { Route as dashboardRepositoriesRepoIdSnapshotIdRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapshotId'
|
||||
import { Route as dashboardRepositoriesRepoIdSnapIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapId.restore'
|
||||
import { Route as dashboardBackupsBackupIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/backups/$backupId.$snapshotId.restore'
|
||||
|
||||
const dashboardRouteRoute = dashboardRouteRouteImport.update({
|
||||
id: '/(dashboard)',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSplatRoute = ApiSplatRouteImport.update({
|
||||
id: '/api/$',
|
||||
path: '/api/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authOnboardingRoute = authOnboardingRouteImport.update({
|
||||
id: '/(auth)/onboarding',
|
||||
path: '/onboarding',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authLoginRoute = authLoginRouteImport.update({
|
||||
id: '/(auth)/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authDownloadRecoveryKeyRoute = authDownloadRecoveryKeyRouteImport.update({
|
||||
id: '/(auth)/download-recovery-key',
|
||||
path: '/download-recovery-key',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const dashboardVolumesIndexRoute = dashboardVolumesIndexRouteImport.update({
|
||||
id: '/volumes/',
|
||||
path: '/volumes/',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardSettingsIndexRoute = dashboardSettingsIndexRouteImport.update({
|
||||
id: '/settings/',
|
||||
path: '/settings/',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardRepositoriesIndexRoute =
|
||||
dashboardRepositoriesIndexRouteImport.update({
|
||||
id: '/repositories/',
|
||||
path: '/repositories/',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardNotificationsIndexRoute =
|
||||
dashboardNotificationsIndexRouteImport.update({
|
||||
id: '/notifications/',
|
||||
path: '/notifications/',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardBackupsIndexRoute = dashboardBackupsIndexRouteImport.update({
|
||||
id: '/backups/',
|
||||
path: '/backups/',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardVolumesCreateRoute = dashboardVolumesCreateRouteImport.update({
|
||||
id: '/volumes/create',
|
||||
path: '/volumes/create',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardVolumesVolumeIdRoute =
|
||||
dashboardVolumesVolumeIdRouteImport.update({
|
||||
id: '/volumes/$volumeId',
|
||||
path: '/volumes/$volumeId',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardRepositoriesCreateRoute =
|
||||
dashboardRepositoriesCreateRouteImport.update({
|
||||
id: '/repositories/create',
|
||||
path: '/repositories/create',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardRepositoriesRepositoryIdRoute =
|
||||
dashboardRepositoriesRepositoryIdRouteImport.update({
|
||||
id: '/repositories/$repositoryId',
|
||||
path: '/repositories/$repositoryId',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardNotificationsCreateRoute =
|
||||
dashboardNotificationsCreateRouteImport.update({
|
||||
id: '/notifications/create',
|
||||
path: '/notifications/create',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardNotificationsNotificationIdRoute =
|
||||
dashboardNotificationsNotificationIdRouteImport.update({
|
||||
id: '/notifications/$notificationId',
|
||||
path: '/notifications/$notificationId',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardBackupsCreateRoute = dashboardBackupsCreateRouteImport.update({
|
||||
id: '/backups/create',
|
||||
path: '/backups/create',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardBackupsScheduleIdRoute =
|
||||
dashboardBackupsScheduleIdRouteImport.update({
|
||||
id: '/backups/$scheduleId',
|
||||
path: '/backups/$scheduleId',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardRepositoriesRepoIdSnapshotIdRoute =
|
||||
dashboardRepositoriesRepoIdSnapshotIdRouteImport.update({
|
||||
id: '/repositories/$repoId/$snapshotId',
|
||||
path: '/repositories/$repoId/$snapshotId',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardRepositoriesRepoIdSnapIdRestoreRoute =
|
||||
dashboardRepositoriesRepoIdSnapIdRestoreRouteImport.update({
|
||||
id: '/repositories/$repoId/$snapId/restore',
|
||||
path: '/repositories/$repoId/$snapId/restore',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
const dashboardBackupsBackupIdSnapshotIdRestoreRoute =
|
||||
dashboardBackupsBackupIdSnapshotIdRestoreRouteImport.update({
|
||||
id: '/backups/$backupId/$snapshotId/restore',
|
||||
path: '/backups/$backupId/$snapshotId/restore',
|
||||
getParentRoute: () => dashboardRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/download-recovery-key': typeof authDownloadRecoveryKeyRoute
|
||||
'/login': typeof authLoginRoute
|
||||
'/onboarding': typeof authOnboardingRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute
|
||||
'/backups/create': typeof dashboardBackupsCreateRoute
|
||||
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||
'/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
|
||||
'/repositories/create': typeof dashboardRepositoriesCreateRoute
|
||||
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
||||
'/volumes/create': typeof dashboardVolumesCreateRoute
|
||||
'/backups/': typeof dashboardBackupsIndexRoute
|
||||
'/notifications/': typeof dashboardNotificationsIndexRoute
|
||||
'/repositories/': typeof dashboardRepositoriesIndexRoute
|
||||
'/settings/': typeof dashboardSettingsIndexRoute
|
||||
'/volumes/': typeof dashboardVolumesIndexRoute
|
||||
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
|
||||
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||
'/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/download-recovery-key': typeof authDownloadRecoveryKeyRoute
|
||||
'/login': typeof authLoginRoute
|
||||
'/onboarding': typeof authOnboardingRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute
|
||||
'/backups/create': typeof dashboardBackupsCreateRoute
|
||||
'/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||
'/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
|
||||
'/repositories/create': typeof dashboardRepositoriesCreateRoute
|
||||
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
||||
'/volumes/create': typeof dashboardVolumesCreateRoute
|
||||
'/backups': typeof dashboardBackupsIndexRoute
|
||||
'/notifications': typeof dashboardNotificationsIndexRoute
|
||||
'/repositories': typeof dashboardRepositoriesIndexRoute
|
||||
'/settings': typeof dashboardSettingsIndexRoute
|
||||
'/volumes': typeof dashboardVolumesIndexRoute
|
||||
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
|
||||
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||
'/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/(dashboard)': typeof dashboardRouteRouteWithChildren
|
||||
'/(auth)/download-recovery-key': typeof authDownloadRecoveryKeyRoute
|
||||
'/(auth)/login': typeof authLoginRoute
|
||||
'/(auth)/onboarding': typeof authOnboardingRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/(dashboard)/backups/$scheduleId': typeof dashboardBackupsScheduleIdRoute
|
||||
'/(dashboard)/backups/create': typeof dashboardBackupsCreateRoute
|
||||
'/(dashboard)/notifications/$notificationId': typeof dashboardNotificationsNotificationIdRoute
|
||||
'/(dashboard)/notifications/create': typeof dashboardNotificationsCreateRoute
|
||||
'/(dashboard)/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
|
||||
'/(dashboard)/repositories/create': typeof dashboardRepositoriesCreateRoute
|
||||
'/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
|
||||
'/(dashboard)/volumes/create': typeof dashboardVolumesCreateRoute
|
||||
'/(dashboard)/backups/': typeof dashboardBackupsIndexRoute
|
||||
'/(dashboard)/notifications/': typeof dashboardNotificationsIndexRoute
|
||||
'/(dashboard)/repositories/': typeof dashboardRepositoriesIndexRoute
|
||||
'/(dashboard)/settings/': typeof dashboardSettingsIndexRoute
|
||||
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
|
||||
'/(dashboard)/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
|
||||
'/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||
'/(dashboard)/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/download-recovery-key'
|
||||
| '/login'
|
||||
| '/onboarding'
|
||||
| '/api/$'
|
||||
| '/backups/$scheduleId'
|
||||
| '/backups/create'
|
||||
| '/notifications/$notificationId'
|
||||
| '/notifications/create'
|
||||
| '/repositories/$repositoryId'
|
||||
| '/repositories/create'
|
||||
| '/volumes/$volumeId'
|
||||
| '/volumes/create'
|
||||
| '/backups/'
|
||||
| '/notifications/'
|
||||
| '/repositories/'
|
||||
| '/settings/'
|
||||
| '/volumes/'
|
||||
| '/repositories/$repoId/$snapshotId'
|
||||
| '/backups/$backupId/$snapshotId/restore'
|
||||
| '/repositories/$repoId/$snapId/restore'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/download-recovery-key'
|
||||
| '/login'
|
||||
| '/onboarding'
|
||||
| '/api/$'
|
||||
| '/backups/$scheduleId'
|
||||
| '/backups/create'
|
||||
| '/notifications/$notificationId'
|
||||
| '/notifications/create'
|
||||
| '/repositories/$repositoryId'
|
||||
| '/repositories/create'
|
||||
| '/volumes/$volumeId'
|
||||
| '/volumes/create'
|
||||
| '/backups'
|
||||
| '/notifications'
|
||||
| '/repositories'
|
||||
| '/settings'
|
||||
| '/volumes'
|
||||
| '/repositories/$repoId/$snapshotId'
|
||||
| '/backups/$backupId/$snapshotId/restore'
|
||||
| '/repositories/$repoId/$snapId/restore'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/(dashboard)'
|
||||
| '/(auth)/download-recovery-key'
|
||||
| '/(auth)/login'
|
||||
| '/(auth)/onboarding'
|
||||
| '/api/$'
|
||||
| '/(dashboard)/backups/$scheduleId'
|
||||
| '/(dashboard)/backups/create'
|
||||
| '/(dashboard)/notifications/$notificationId'
|
||||
| '/(dashboard)/notifications/create'
|
||||
| '/(dashboard)/repositories/$repositoryId'
|
||||
| '/(dashboard)/repositories/create'
|
||||
| '/(dashboard)/volumes/$volumeId'
|
||||
| '/(dashboard)/volumes/create'
|
||||
| '/(dashboard)/backups/'
|
||||
| '/(dashboard)/notifications/'
|
||||
| '/(dashboard)/repositories/'
|
||||
| '/(dashboard)/settings/'
|
||||
| '/(dashboard)/volumes/'
|
||||
| '/(dashboard)/repositories/$repoId/$snapshotId'
|
||||
| '/(dashboard)/backups/$backupId/$snapshotId/restore'
|
||||
| '/(dashboard)/repositories/$repoId/$snapId/restore'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
dashboardRouteRoute: typeof dashboardRouteRouteWithChildren
|
||||
authDownloadRecoveryKeyRoute: typeof authDownloadRecoveryKeyRoute
|
||||
authLoginRoute: typeof authLoginRoute
|
||||
authOnboardingRoute: typeof authOnboardingRoute
|
||||
ApiSplatRoute: typeof ApiSplatRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/(dashboard)': {
|
||||
id: '/(dashboard)'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
preLoaderRoute: typeof dashboardRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/$': {
|
||||
id: '/api/$'
|
||||
path: '/api/$'
|
||||
fullPath: '/api/$'
|
||||
preLoaderRoute: typeof ApiSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/onboarding': {
|
||||
id: '/(auth)/onboarding'
|
||||
path: '/onboarding'
|
||||
fullPath: '/onboarding'
|
||||
preLoaderRoute: typeof authOnboardingRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/login': {
|
||||
id: '/(auth)/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof authLoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/download-recovery-key': {
|
||||
id: '/(auth)/download-recovery-key'
|
||||
path: '/download-recovery-key'
|
||||
fullPath: '/download-recovery-key'
|
||||
preLoaderRoute: typeof authDownloadRecoveryKeyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(dashboard)/volumes/': {
|
||||
id: '/(dashboard)/volumes/'
|
||||
path: '/volumes'
|
||||
fullPath: '/volumes/'
|
||||
preLoaderRoute: typeof dashboardVolumesIndexRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/settings/': {
|
||||
id: '/(dashboard)/settings/'
|
||||
path: '/settings'
|
||||
fullPath: '/settings/'
|
||||
preLoaderRoute: typeof dashboardSettingsIndexRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/repositories/': {
|
||||
id: '/(dashboard)/repositories/'
|
||||
path: '/repositories'
|
||||
fullPath: '/repositories/'
|
||||
preLoaderRoute: typeof dashboardRepositoriesIndexRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/notifications/': {
|
||||
id: '/(dashboard)/notifications/'
|
||||
path: '/notifications'
|
||||
fullPath: '/notifications/'
|
||||
preLoaderRoute: typeof dashboardNotificationsIndexRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/backups/': {
|
||||
id: '/(dashboard)/backups/'
|
||||
path: '/backups'
|
||||
fullPath: '/backups/'
|
||||
preLoaderRoute: typeof dashboardBackupsIndexRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/volumes/create': {
|
||||
id: '/(dashboard)/volumes/create'
|
||||
path: '/volumes/create'
|
||||
fullPath: '/volumes/create'
|
||||
preLoaderRoute: typeof dashboardVolumesCreateRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/volumes/$volumeId': {
|
||||
id: '/(dashboard)/volumes/$volumeId'
|
||||
path: '/volumes/$volumeId'
|
||||
fullPath: '/volumes/$volumeId'
|
||||
preLoaderRoute: typeof dashboardVolumesVolumeIdRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/repositories/create': {
|
||||
id: '/(dashboard)/repositories/create'
|
||||
path: '/repositories/create'
|
||||
fullPath: '/repositories/create'
|
||||
preLoaderRoute: typeof dashboardRepositoriesCreateRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/repositories/$repositoryId': {
|
||||
id: '/(dashboard)/repositories/$repositoryId'
|
||||
path: '/repositories/$repositoryId'
|
||||
fullPath: '/repositories/$repositoryId'
|
||||
preLoaderRoute: typeof dashboardRepositoriesRepositoryIdRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/notifications/create': {
|
||||
id: '/(dashboard)/notifications/create'
|
||||
path: '/notifications/create'
|
||||
fullPath: '/notifications/create'
|
||||
preLoaderRoute: typeof dashboardNotificationsCreateRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/notifications/$notificationId': {
|
||||
id: '/(dashboard)/notifications/$notificationId'
|
||||
path: '/notifications/$notificationId'
|
||||
fullPath: '/notifications/$notificationId'
|
||||
preLoaderRoute: typeof dashboardNotificationsNotificationIdRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/backups/create': {
|
||||
id: '/(dashboard)/backups/create'
|
||||
path: '/backups/create'
|
||||
fullPath: '/backups/create'
|
||||
preLoaderRoute: typeof dashboardBackupsCreateRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/backups/$scheduleId': {
|
||||
id: '/(dashboard)/backups/$scheduleId'
|
||||
path: '/backups/$scheduleId'
|
||||
fullPath: '/backups/$scheduleId'
|
||||
preLoaderRoute: typeof dashboardBackupsScheduleIdRouteImport
|
||||
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
|
||||
}
|
||||
'/(dashboard)/repositories/$repoId/$snapId/restore': {
|
||||
id: '/(dashboard)/repositories/$repoId/$snapId/restore'
|
||||
path: '/repositories/$repoId/$snapId/restore'
|
||||
fullPath: '/repositories/$repoId/$snapId/restore'
|
||||
preLoaderRoute: typeof dashboardRepositoriesRepoIdSnapIdRestoreRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
'/(dashboard)/backups/$backupId/$snapshotId/restore': {
|
||||
id: '/(dashboard)/backups/$backupId/$snapshotId/restore'
|
||||
path: '/backups/$backupId/$snapshotId/restore'
|
||||
fullPath: '/backups/$backupId/$snapshotId/restore'
|
||||
preLoaderRoute: typeof dashboardBackupsBackupIdSnapshotIdRestoreRouteImport
|
||||
parentRoute: typeof dashboardRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface dashboardRouteRouteChildren {
|
||||
dashboardBackupsScheduleIdRoute: typeof dashboardBackupsScheduleIdRoute
|
||||
dashboardBackupsCreateRoute: typeof dashboardBackupsCreateRoute
|
||||
dashboardNotificationsNotificationIdRoute: typeof dashboardNotificationsNotificationIdRoute
|
||||
dashboardNotificationsCreateRoute: typeof dashboardNotificationsCreateRoute
|
||||
dashboardRepositoriesRepositoryIdRoute: typeof dashboardRepositoriesRepositoryIdRoute
|
||||
dashboardRepositoriesCreateRoute: typeof dashboardRepositoriesCreateRoute
|
||||
dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute
|
||||
dashboardVolumesCreateRoute: typeof dashboardVolumesCreateRoute
|
||||
dashboardBackupsIndexRoute: typeof dashboardBackupsIndexRoute
|
||||
dashboardNotificationsIndexRoute: typeof dashboardNotificationsIndexRoute
|
||||
dashboardRepositoriesIndexRoute: typeof dashboardRepositoriesIndexRoute
|
||||
dashboardSettingsIndexRoute: typeof dashboardSettingsIndexRoute
|
||||
dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute
|
||||
dashboardRepositoriesRepoIdSnapshotIdRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRoute
|
||||
dashboardBackupsBackupIdSnapshotIdRestoreRoute: typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
|
||||
dashboardRepositoriesRepoIdSnapIdRestoreRoute: typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
|
||||
}
|
||||
|
||||
const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
|
||||
dashboardBackupsScheduleIdRoute: dashboardBackupsScheduleIdRoute,
|
||||
dashboardBackupsCreateRoute: dashboardBackupsCreateRoute,
|
||||
dashboardNotificationsNotificationIdRoute:
|
||||
dashboardNotificationsNotificationIdRoute,
|
||||
dashboardNotificationsCreateRoute: dashboardNotificationsCreateRoute,
|
||||
dashboardRepositoriesRepositoryIdRoute:
|
||||
dashboardRepositoriesRepositoryIdRoute,
|
||||
dashboardRepositoriesCreateRoute: dashboardRepositoriesCreateRoute,
|
||||
dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute,
|
||||
dashboardVolumesCreateRoute: dashboardVolumesCreateRoute,
|
||||
dashboardBackupsIndexRoute: dashboardBackupsIndexRoute,
|
||||
dashboardNotificationsIndexRoute: dashboardNotificationsIndexRoute,
|
||||
dashboardRepositoriesIndexRoute: dashboardRepositoriesIndexRoute,
|
||||
dashboardSettingsIndexRoute: dashboardSettingsIndexRoute,
|
||||
dashboardVolumesIndexRoute: dashboardVolumesIndexRoute,
|
||||
dashboardRepositoriesRepoIdSnapshotIdRoute:
|
||||
dashboardRepositoriesRepoIdSnapshotIdRoute,
|
||||
dashboardBackupsBackupIdSnapshotIdRestoreRoute:
|
||||
dashboardBackupsBackupIdSnapshotIdRestoreRoute,
|
||||
dashboardRepositoriesRepoIdSnapIdRestoreRoute:
|
||||
dashboardRepositoriesRepoIdSnapIdRestoreRoute,
|
||||
}
|
||||
|
||||
const dashboardRouteRouteWithChildren = dashboardRouteRoute._addFileChildren(
|
||||
dashboardRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
dashboardRouteRoute: dashboardRouteRouteWithChildren,
|
||||
authDownloadRecoveryKeyRoute: authDownloadRecoveryKeyRoute,
|
||||
authLoginRoute: authLoginRoute,
|
||||
authOnboardingRoute: authOnboardingRoute,
|
||||
ApiSplatRoute: ApiSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
52
app/router.tsx
Normal file
52
app/router.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { createRouter } from "@tanstack/react-router";
|
||||
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { MutationCache, QueryClient } from "@tanstack/react-query";
|
||||
import { client } from "./client/api-client/client.gen";
|
||||
import type { BreadcrumbItemData } from "./client/components/app-breadcrumb";
|
||||
|
||||
client.setConfig({
|
||||
baseUrl: "/",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
export function getRouter() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
mutationCache: new MutationCache({
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Mutation error:", error);
|
||||
void queryClient.invalidateQueries();
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
});
|
||||
setupRouterSsrQueryIntegration({
|
||||
router,
|
||||
queryClient,
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>;
|
||||
}
|
||||
interface StaticDataRouteOption {
|
||||
breadcrumb?: (match: any) => BreadcrumbItemData[] | null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { layout, type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
route("onboarding", "./client/modules/auth/routes/onboarding.tsx"),
|
||||
route("login", "./client/modules/auth/routes/login.tsx"),
|
||||
route("download-recovery-key", "./client/modules/auth/routes/download-recovery-key.tsx"),
|
||||
layout("./client/components/layout.tsx", [
|
||||
route("/", "./client/routes/root.tsx"),
|
||||
route("volumes", "./client/modules/volumes/routes/volumes.tsx"),
|
||||
route("volumes/create", "./client/modules/volumes/routes/create-volume.tsx"),
|
||||
route("volumes/:id", "./client/modules/volumes/routes/volume-details.tsx"),
|
||||
route("backups", "./client/modules/backups/routes/backups.tsx"),
|
||||
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
|
||||
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
|
||||
route("backups/:id/:snapshotId/restore", "./client/modules/backups/routes/restore-snapshot.tsx"),
|
||||
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
|
||||
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
|
||||
route("repositories/:id", "./client/modules/repositories/routes/repository-details.tsx"),
|
||||
route("repositories/:id/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
||||
route("repositories/:id/:snapshotId/restore", "./client/modules/repositories/routes/restore-snapshot.tsx"),
|
||||
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
|
||||
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
|
||||
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),
|
||||
route("settings", "./client/modules/settings/routes/settings.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
15
app/routes/(auth)/download-recovery-key.tsx
Normal file
15
app/routes/(auth)/download-recovery-key.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DownloadRecoveryKeyPage } from "~/client/modules/auth/routes/download-recovery-key";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/download-recovery-key")({
|
||||
component: DownloadRecoveryKeyPage,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Download Recovery Key" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Download your backup recovery key to ensure you can restore your data.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
15
app/routes/(auth)/login.tsx
Normal file
15
app/routes/(auth)/login.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/login")({
|
||||
component: LoginPage,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Login" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Sign in to your Zerobyte account.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
15
app/routes/(auth)/onboarding.tsx
Normal file
15
app/routes/(auth)/onboarding.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { OnboardingPage } from "~/client/modules/auth/routes/onboarding";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/onboarding")({
|
||||
component: OnboardingPage,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Onboarding" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Welcome to Zerobyte. Create your admin account to get started.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getBackupSchedule } from "~/client/api-client";
|
||||
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ params, context }) => {
|
||||
const schedule = await getBackupSchedule({ path: { scheduleId: params.backupId } });
|
||||
|
||||
if (!schedule.data) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const [snapshot, repository] = await Promise.all([
|
||||
context.queryClient.ensureQueryData({
|
||||
...getSnapshotDetailsOptions({ path: { id: schedule.data?.repositoryId, snapshotId: params.snapshotId } }),
|
||||
}),
|
||||
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: schedule.data?.repositoryId } }) }),
|
||||
]);
|
||||
|
||||
return { snapshot, repository, schedule: schedule.data };
|
||||
},
|
||||
head: ({ params }) => ({
|
||||
meta: [
|
||||
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Restore files from a backup snapshot.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Backup Jobs", href: "/backups" },
|
||||
{ label: match.loaderData?.schedule?.name || "Job", href: `/backups/${match.params.backupId}` },
|
||||
{ label: match.params.snapshotId },
|
||||
{ label: "Restore" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { backupId, snapshotId } = Route.useParams();
|
||||
const { snapshot, repository } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<RestoreSnapshotPage
|
||||
returnPath={`/backups/${backupId}`}
|
||||
snapshotId={snapshotId}
|
||||
snapshot={snapshot}
|
||||
repository={repository}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
app/routes/(dashboard)/backups/$scheduleId.tsx
Normal file
53
app/routes/(dashboard)/backups/$scheduleId.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
getBackupScheduleOptions,
|
||||
getScheduleMirrorsOptions,
|
||||
getScheduleNotificationsOptions,
|
||||
listNotificationDestinationsOptions,
|
||||
listRepositoriesOptions,
|
||||
listSnapshotsOptions,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { ScheduleDetailsPage } from "~/client/modules/backups/routes/backup-details";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/backups/$scheduleId")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ params, context }) => {
|
||||
const { scheduleId } = params;
|
||||
|
||||
const [schedule, notifs, repos, scheduleNotifs, mirrors] = await Promise.all([
|
||||
context.queryClient.ensureQueryData({ ...getBackupScheduleOptions({ path: { scheduleId } }) }),
|
||||
context.queryClient.ensureQueryData({ ...listNotificationDestinationsOptions() }),
|
||||
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
|
||||
context.queryClient.ensureQueryData({ ...getScheduleNotificationsOptions({ path: { scheduleId } }) }),
|
||||
context.queryClient.ensureQueryData({ ...getScheduleMirrorsOptions({ path: { scheduleId } }) }),
|
||||
]);
|
||||
|
||||
void context.queryClient.prefetchQuery({
|
||||
...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.shortId } }),
|
||||
});
|
||||
|
||||
return { schedule, notifs, repos, scheduleNotifs, mirrors };
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Backup Jobs", href: "/backups" },
|
||||
{ label: match.loaderData?.schedule.name || "Job Details" },
|
||||
],
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{ title: `Zerobyte - ${loaderData?.schedule.name || "Backup Job Details"}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const loaderData = Route.useLoaderData();
|
||||
const { scheduleId } = Route.useParams();
|
||||
|
||||
return <ScheduleDetailsPage loaderData={loaderData} scheduleId={scheduleId} />;
|
||||
}
|
||||
20
app/routes/(dashboard)/backups/create.tsx
Normal file
20
app/routes/(dashboard)/backups/create.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listRepositoriesOptions, listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { CreateBackupPage } from "~/client/modules/backups/routes/create-backup";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/backups/create")({
|
||||
loader: async ({ context }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.ensureQueryData({ ...listVolumesOptions() }),
|
||||
context.queryClient.ensureQueryData({ ...listRepositoriesOptions() }),
|
||||
]);
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: () => [{ label: "Backup Jobs", href: "/backups" }, { label: "Create" }],
|
||||
},
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <CreateBackupPage />;
|
||||
}
|
||||
28
app/routes/(dashboard)/backups/index.tsx
Normal file
28
app/routes/(dashboard)/backups/index.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { BackupsPage } from "~/client/modules/backups/routes/backups";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/backups/")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureQueryData({
|
||||
...listBackupSchedulesOptions(),
|
||||
});
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: () => [{ label: "Backup Jobs" }],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Backup Jobs" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <BackupsPage />;
|
||||
}
|
||||
33
app/routes/(dashboard)/notifications/$notificationId.tsx
Normal file
33
app/routes/(dashboard)/notifications/$notificationId.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getNotificationDestinationOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { NotificationDetailsPage } from "~/client/modules/notifications/routes/notification-details";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/notifications/$notificationId")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ params, context }) => {
|
||||
const res = await context.queryClient.ensureQueryData({
|
||||
...getNotificationDestinationOptions({ path: { id: params.notificationId } }),
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Notifications", href: "/notifications" },
|
||||
{ label: match.loaderData?.name || "Notification Details" },
|
||||
],
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{ title: `Zerobyte - ${loaderData?.name}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and edit notification destination settings.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <NotificationDetailsPage notificationId={Route.useParams().notificationId} />;
|
||||
}
|
||||
25
app/routes/(dashboard)/notifications/create.tsx
Normal file
25
app/routes/(dashboard)/notifications/create.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CreateNotificationPage } from "~/client/modules/notifications/routes/create-notification";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/notifications/create")({
|
||||
component: RouteComponent,
|
||||
staticData: {
|
||||
breadcrumb: () => [
|
||||
{ label: "Notifications", href: "/notifications" },
|
||||
{ label: "Create" },
|
||||
],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Create Notification" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new notification destination for backup alerts.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <CreateNotificationPage />;
|
||||
}
|
||||
26
app/routes/(dashboard)/notifications/index.tsx
Normal file
26
app/routes/(dashboard)/notifications/index.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { NotificationsPage } from "~/client/modules/notifications/routes/notifications";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/notifications/")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureQueryData({ ...listNotificationDestinationsOptions() });
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: () => [{ label: "Notifications" }],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Notifications" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage notification destinations for backup alerts.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <NotificationsPage />;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapId/restore")({
|
||||
component: RouteComponent,
|
||||
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||
loader: async ({ params, context }) => {
|
||||
const [snapshot, repository] = await Promise.all([
|
||||
context.queryClient.ensureQueryData({
|
||||
...getSnapshotDetailsOptions({ path: { id: params.repoId, snapshotId: params.snapId } }),
|
||||
}),
|
||||
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repoId } }) }),
|
||||
]);
|
||||
|
||||
return { snapshot, repository };
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
{ label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repoId}` },
|
||||
{ label: match.params.snapId, href: `/repositories/${match.params.repoId}/${match.params.snapId}` },
|
||||
{ label: "Restore" },
|
||||
],
|
||||
},
|
||||
head: ({ params }) => ({
|
||||
meta: [
|
||||
{ title: `Zerobyte - Restore Snapshot ${params.snapId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Restore files from a backup snapshot.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { repoId, snapId } = Route.useParams();
|
||||
const { snapshot, repository } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<RestoreSnapshotPage
|
||||
returnPath={`/repositories/${repoId}/${snapId}`}
|
||||
snapshot={snapshot}
|
||||
repository={repository}
|
||||
snapshotId={snapId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
app/routes/(dashboard)/repositories/$repoId.$snapshotId.tsx
Normal file
53
app/routes/(dashboard)/repositories/$repoId.$snapshotId.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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 }) => {
|
||||
const res = await context.queryClient.ensureQueryData({
|
||||
...getRepositoryOptions({ path: { id: params.repoId } }),
|
||||
});
|
||||
|
||||
void context.queryClient.prefetchQuery({
|
||||
...getSnapshotDetailsOptions({
|
||||
path: { id: params.repoId, snapshotId: params.snapshotId },
|
||||
}),
|
||||
});
|
||||
void context.queryClient.prefetchQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { id: params.repoId, snapshotId: params.snapshotId },
|
||||
query: { path: "/" },
|
||||
}),
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
{ label: match.loaderData?.name || "Repository", href: `/repositories/${match.params.repoId}` },
|
||||
{ label: match.params.snapshotId },
|
||||
],
|
||||
},
|
||||
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} />;
|
||||
}
|
||||
49
app/routes/(dashboard)/repositories/$repositoryId.tsx
Normal file
49
app/routes/(dashboard)/repositories/$repositoryId.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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 }) => {
|
||||
void context.queryClient.prefetchQuery({
|
||||
...listSnapshotsOptions({ path: { id: params.repositoryId } }),
|
||||
});
|
||||
void context.queryClient.prefetchQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
});
|
||||
|
||||
const res = await context.queryClient.ensureQueryData({
|
||||
...getRepositoryOptions({ path: { id: params.repositoryId } }),
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
validateSearch: type({ tab: "string?" }),
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
{ label: match.loaderData?.name || "Repository Details" },
|
||||
],
|
||||
},
|
||||
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} />;
|
||||
}
|
||||
25
app/routes/(dashboard)/repositories/create.tsx
Normal file
25
app/routes/(dashboard)/repositories/create.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CreateRepositoryPage } from "~/client/modules/repositories/routes/create-repository";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/repositories/create")({
|
||||
component: RouteComponent,
|
||||
staticData: {
|
||||
breadcrumb: () => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
{ label: "Create" },
|
||||
],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Create Repository" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new backup repository with encryption and compression.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <CreateRepositoryPage />;
|
||||
}
|
||||
29
app/routes/(dashboard)/repositories/index.tsx
Normal file
29
app/routes/(dashboard)/repositories/index.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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>,
|
||||
staticData: {
|
||||
breadcrumb: () => [{ label: "Repositories" }],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Repositories" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your backup repositories with encryption and compression.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <RepositoriesPage />;
|
||||
}
|
||||
33
app/routes/(dashboard)/route.tsx
Normal file
33
app/routes/(dashboard)/route.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import { Layout } from "~/client/components/layout";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
import { authService } from "~/server/modules/auth/auth.service";
|
||||
|
||||
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const headers = getRequestHeaders();
|
||||
const session = await auth.api.getSession({ headers });
|
||||
const hasUsers = await authService.hasUsers();
|
||||
|
||||
return { user: session?.user ?? null, hasUsers };
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)")({
|
||||
component: PathlessLayoutComponent,
|
||||
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||
server: {
|
||||
middleware: [authMiddleware],
|
||||
},
|
||||
loader: async () => {
|
||||
const authContext = await fetchUser();
|
||||
return authContext;
|
||||
},
|
||||
});
|
||||
|
||||
function PathlessLayoutComponent() {
|
||||
const loaderData = Route.useLoaderData();
|
||||
|
||||
return <Layout loaderData={loaderData} />;
|
||||
}
|
||||
22
app/routes/(dashboard)/settings/index.tsx
Normal file
22
app/routes/(dashboard)/settings/index.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { fetchUser } from "../route";
|
||||
import type { AppContext } from "~/context";
|
||||
import { SettingsPage } from "~/client/modules/settings/routes/settings";
|
||||
import { type } from "arktype";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/settings/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: type({ tab: "string?" }),
|
||||
loader: async () => {
|
||||
const authContext = await fetchUser();
|
||||
return authContext as AppContext;
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: () => [{ label: "Settings" }],
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const appContext = Route.useLoaderData();
|
||||
return <SettingsPage appContext={appContext} />;
|
||||
}
|
||||
36
app/routes/(dashboard)/volumes/$volumeId.tsx
Normal file
36
app/routes/(dashboard)/volumes/$volumeId.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { type } from "arktype";
|
||||
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
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 } }),
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
validateSearch: type({ tab: "string?" }),
|
||||
staticData: {
|
||||
breadcrumb: (match) => [
|
||||
{ label: "Volumes", href: "/volumes" },
|
||||
{ label: match.loaderData?.volume.name || "Volume Details" },
|
||||
],
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{ title: `Zerobyte - ${loaderData?.volume.name}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <VolumeDetails volumeId={Route.useParams().volumeId} />;
|
||||
}
|
||||
22
app/routes/(dashboard)/volumes/create.tsx
Normal file
22
app/routes/(dashboard)/volumes/create.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CreateVolumePage } from "~/client/modules/volumes/routes/create-volume";
|
||||
|
||||
export const Route = createFileRoute("/(dashboard)/volumes/create")({
|
||||
component: RouteComponent,
|
||||
staticData: {
|
||||
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Create Volume" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create a new storage volume with automatic mounting and health checks.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <CreateVolumePage />;
|
||||
}
|
||||
23
app/routes/(dashboard)/volumes/index.tsx
Normal file
23
app/routes/(dashboard)/volumes/index.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
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());
|
||||
},
|
||||
staticData: {
|
||||
breadcrumb: () => [{ label: "Volumes" }],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte - Volumes" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
62
app/routes/__root.tsx
Normal file
62
app/routes/__root.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Outlet, HeadContent, Scripts, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import appCss from "../app.css?url";
|
||||
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";
|
||||
import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||
|
||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||
server: {
|
||||
middleware: [apiClientMiddleware],
|
||||
},
|
||||
head: () => ({
|
||||
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
||||
links: [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: appCss,
|
||||
},
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&display=swap",
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: RootLayout,
|
||||
errorComponent: (e) => <div>{e.error.message}</div>,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
useServerEvents();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<link rel="icon" type="image/png" href="/images/favicon/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/images/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Zerobyte" />
|
||||
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="dark">
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<ReactQueryDevtools buttonPosition="bottom-left" />
|
||||
<Toaster />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
18
app/routes/api.$.ts
Normal file
18
app/routes/api.$.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createApp } from "~/server/app";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
const handle = ({ request }: { request: Request }) => app.fetch(request.clone());
|
||||
|
||||
export const Route = createFileRoute("/api/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handle,
|
||||
POST: handle,
|
||||
DELETE: handle,
|
||||
PUT: handle,
|
||||
PATCH: handle,
|
||||
},
|
||||
},
|
||||
});
|
||||
16
app/routes/index.tsx
Normal file
16
app/routes/index.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Zerobyte" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Zerobyte - Manage your backups and storage volumes with ease.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/volumes" });
|
||||
},
|
||||
});
|
||||
53
app/server.ts
Normal file
53
app/server.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import * as schema from "./server/db/schema";
|
||||
import { setSchema, runDbMigrations } from "./server/db/db";
|
||||
import { startup } from "./server/modules/lifecycle/startup";
|
||||
import { logger } from "./server/utils/logger";
|
||||
import { shutdown } from "./server/modules/lifecycle/shutdown";
|
||||
import { runCLI } from "./server/cli";
|
||||
import { runMigrations } from "./server/modules/lifecycle/migrations";
|
||||
import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server";
|
||||
import { createServerEntry } from "@tanstack/react-start/server-entry";
|
||||
|
||||
setSchema(schema);
|
||||
|
||||
const cliRun = await runCLI(Bun.argv);
|
||||
if (cliRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runDbMigrations();
|
||||
|
||||
await runMigrations();
|
||||
await startup();
|
||||
|
||||
const customHandler = defineHandlerCallback((ctx) => {
|
||||
return defaultStreamHandler(ctx);
|
||||
});
|
||||
|
||||
const fetch = createStartHandler(customHandler);
|
||||
|
||||
export default createServerEntry({
|
||||
fetch,
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("SIGTERM received, starting graceful shutdown...");
|
||||
try {
|
||||
await shutdown();
|
||||
} catch (err) {
|
||||
logger.error("Error during shutdown", err);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("SIGINT received, starting graceful shutdown...");
|
||||
try {
|
||||
await shutdown();
|
||||
} catch (err) {
|
||||
logger.error("Error during shutdown", err);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
|
@ -70,7 +70,7 @@ export const createApp = () => {
|
|||
);
|
||||
|
||||
app
|
||||
.get("healthcheck", (c) => c.json({ status: "ok" }))
|
||||
.get("/api/healthcheck", (c) => c.json({ status: "ok" }))
|
||||
.route("/api/v1/auth", authController)
|
||||
.route("/api/v1/volumes", volumeController)
|
||||
.route("/api/v1/repositories", repositoriesController)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ const envSchema = type({
|
|||
port: s.PORT,
|
||||
migrationsPath: s.MIGRATIONS_PATH,
|
||||
appVersion: s.APP_VERSION,
|
||||
trustedOrigins: s.TRUSTED_ORIGINS?.split(",").map((origin) => origin.trim()),
|
||||
trustedOrigins: s.TRUSTED_ORIGINS?.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
.concat(s.BASE_URL) ?? [s.BASE_URL],
|
||||
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true",
|
||||
appSecret: s.APP_SECRET,
|
||||
baseUrl: s.BASE_URL,
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import { createHonoServer } from "react-router-hono-server/bun";
|
||||
import * as schema from "./db/schema";
|
||||
import { setSchema, runDbMigrations } from "./db/db";
|
||||
import { startup } from "./modules/lifecycle/startup";
|
||||
import { logger } from "./utils/logger";
|
||||
import { shutdown } from "./modules/lifecycle/shutdown";
|
||||
import { createApp } from "./app";
|
||||
import { config } from "./core/config";
|
||||
import { runCLI } from "./cli";
|
||||
import { runMigrations } from "./modules/lifecycle/migrations";
|
||||
|
||||
setSchema(schema);
|
||||
|
||||
const cliRun = await runCLI(Bun.argv);
|
||||
if (cliRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runDbMigrations();
|
||||
|
||||
const app = createApp();
|
||||
|
||||
await runMigrations();
|
||||
await startup();
|
||||
|
||||
export type AppType = typeof app;
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("SIGTERM received, starting graceful shutdown...");
|
||||
await shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("SIGINT received, starting graceful shutdown...");
|
||||
await shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
export default await createHonoServer({
|
||||
app,
|
||||
port: config.port,
|
||||
defaultLogger: false,
|
||||
customBunServer: {
|
||||
idleTimeout: config.serverIdleTimeout,
|
||||
error(err) {
|
||||
logger.error(`[Bun.serve] Server error: ${err.message}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import { cryptoUtils } from "../utils/crypto";
|
|||
import { organization as organizationTable, member, usersTable } from "../db/schema";
|
||||
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
|
||||
import { authService } from "../modules/auth/auth.service";
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
|
||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||
|
||||
|
|
@ -149,6 +150,7 @@ const createBetterAuth = (secret: string) => {
|
|||
amount: 5,
|
||||
},
|
||||
}),
|
||||
tanstackStartCookies(),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { test, describe, expect } from "bun:test";
|
||||
import { createApp } from "~/server/app";
|
||||
import { serverEvents } from "~/server/core/events";
|
||||
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
||||
|
||||
const app = createApp();
|
||||
|
|
@ -30,6 +31,32 @@ describe("events security", () => {
|
|||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
await res.body?.cancel();
|
||||
});
|
||||
|
||||
test("should cleanup SSE listeners when client disconnects", async () => {
|
||||
const { token } = await createTestSession();
|
||||
const initialCount = serverEvents.listenerCount("doctor:cancelled");
|
||||
|
||||
const res = await app.request("/api/v1/events", {
|
||||
headers: getAuthHeaders(token),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
for (let i = 0; i < 20 && serverEvents.listenerCount("doctor:cancelled") < initialCount + 1; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
expect(serverEvents.listenerCount("doctor:cancelled")).toBe(initialCount + 1);
|
||||
|
||||
await res.body?.cancel();
|
||||
|
||||
for (let i = 0; i < 20 && serverEvents.listenerCount("doctor:cancelled") > initialCount; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
expect(serverEvents.listenerCount("doctor:cancelled")).toBe(initialCount);
|
||||
});
|
||||
|
||||
describe("unauthenticated access", () => {
|
||||
|
|
|
|||
|
|
@ -162,10 +162,13 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
|||
serverEvents.on("doctor:cancelled", onDoctorCancelled);
|
||||
|
||||
let keepAlive = true;
|
||||
let cleanedUp = false;
|
||||
|
||||
stream.onAbort(() => {
|
||||
logger.info("Client disconnected from SSE endpoint");
|
||||
keepAlive = false;
|
||||
function cleanup() {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
|
||||
c.req.raw.signal.removeEventListener("abort", onRequestAbort);
|
||||
serverEvents.off("backup:started", onBackupStarted);
|
||||
serverEvents.off("backup:progress", onBackupProgress);
|
||||
serverEvents.off("backup:completed", onBackupCompleted);
|
||||
|
|
@ -177,14 +180,33 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
|||
serverEvents.off("doctor:started", onDoctorStarted);
|
||||
serverEvents.off("doctor:completed", onDoctorCompleted);
|
||||
serverEvents.off("doctor:cancelled", onDoctorCancelled);
|
||||
});
|
||||
}
|
||||
|
||||
while (keepAlive) {
|
||||
function handleDisconnect() {
|
||||
if (!keepAlive) return;
|
||||
logger.info("Client disconnected from SSE endpoint");
|
||||
keepAlive = false;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function onRequestAbort() {
|
||||
handleDisconnect();
|
||||
stream.abort();
|
||||
}
|
||||
|
||||
stream.onAbort(handleDisconnect);
|
||||
c.req.raw.signal.addEventListener("abort", onRequestAbort, { once: true });
|
||||
|
||||
try {
|
||||
while (keepAlive && !c.req.raw.signal.aborted && !stream.aborted) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ timestamp: Date.now() }),
|
||||
event: "heartbeat",
|
||||
});
|
||||
await stream.sleep(5000);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ export const snapshotSchema = type({
|
|||
duration: "number",
|
||||
tags: "string[]",
|
||||
retentionCategories: "string[]",
|
||||
hostname: "string?",
|
||||
});
|
||||
|
||||
const listSnapshotsResponse = snapshotSchema.array();
|
||||
|
|
|
|||
|
|
@ -1,41 +1,105 @@
|
|||
import { createLogger, format, transports } from "winston";
|
||||
import { format } from "date-fns";
|
||||
import { createConsola, type ConsolaReporter } from "consola";
|
||||
import { formatWithOptions } from "node:util";
|
||||
import { sanitizeSensitiveData } from "./sanitize";
|
||||
|
||||
const { printf, combine, colorize } = format;
|
||||
|
||||
const printConsole = printf((info) => `${info.level} > ${String(info.message)}`);
|
||||
const consoleFormat = combine(colorize(), printConsole);
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const getDefaultLevel = () => {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
return isProd ? "info" : "debug";
|
||||
};
|
||||
|
||||
const winstonLogger = createLogger({
|
||||
level: process.env.LOG_LEVEL || getDefaultLevel(),
|
||||
format: format.json(),
|
||||
transports: [new transports.Console({ level: process.env.LOG_LEVEL || getDefaultLevel(), format: consoleFormat })],
|
||||
const resolveLogLevel = (): LogLevel => {
|
||||
const raw = (process.env.LOG_LEVEL || getDefaultLevel()).toLowerCase();
|
||||
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
|
||||
return raw;
|
||||
}
|
||||
return getDefaultLevel();
|
||||
};
|
||||
|
||||
const consolaLevel: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 3,
|
||||
debug: 4,
|
||||
};
|
||||
|
||||
const useColor = (() => {
|
||||
if (process.env.NO_COLOR !== undefined) return false;
|
||||
if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") return true;
|
||||
return true;
|
||||
})();
|
||||
|
||||
const levelStyles = {
|
||||
debug: { label: "debug", color: "\x1b[34m" },
|
||||
info: { label: "info", color: "\x1b[32m" },
|
||||
warn: { label: "warn", color: "\x1b[33m" },
|
||||
error: { label: "error", color: "\x1b[31m" },
|
||||
} as const;
|
||||
|
||||
const colorize = (color: string, text: string) => (useColor ? `${color}${text}\x1b[0m` : text);
|
||||
|
||||
const resolveLevel = (type: string | undefined): LogLevel => {
|
||||
if (type === "debug") return "debug";
|
||||
if (type === "warn") return "warn";
|
||||
if (type === "error" || type === "fatal") return "error";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const reporter: ConsolaReporter = {
|
||||
log(logObj, ctx) {
|
||||
const level = resolveLevel(logObj.type);
|
||||
|
||||
const timestamp = colorize("\x1b[90m", format(new Date(), "HH:mm:ss"));
|
||||
const style = levelStyles[level];
|
||||
const prefix = colorize(style.color, style.label);
|
||||
const tag = logObj.tag ? `[${logObj.tag}]` : "";
|
||||
const message = formatWithOptions(
|
||||
{
|
||||
...ctx.options.formatOptions,
|
||||
colors: useColor,
|
||||
},
|
||||
...logObj.args,
|
||||
);
|
||||
const line = [timestamp, prefix, tag, message].filter(Boolean).join(" ");
|
||||
const stream = logObj.level < 2 ? (ctx.options.stderr ?? process.stderr) : (ctx.options.stdout ?? process.stdout);
|
||||
stream.write(line + "\n");
|
||||
},
|
||||
};
|
||||
|
||||
const consola = createConsola({
|
||||
level: consolaLevel[resolveLogLevel()],
|
||||
formatOptions: {
|
||||
colors: true,
|
||||
},
|
||||
reporters: [reporter],
|
||||
});
|
||||
|
||||
const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {
|
||||
const stringMessages = messages.flatMap((m) => {
|
||||
const safeStringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return "[Unserializable object]";
|
||||
}
|
||||
};
|
||||
|
||||
const formatMessages = (messages: unknown[]) =>
|
||||
messages.flatMap((m) => {
|
||||
if (m instanceof Error) {
|
||||
return [sanitizeSensitiveData(m.message), m.stack ? sanitizeSensitiveData(m.stack) : undefined].filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof m === "object") {
|
||||
return sanitizeSensitiveData(JSON.stringify(m, null, 2));
|
||||
return sanitizeSensitiveData(safeStringify(m));
|
||||
}
|
||||
|
||||
return sanitizeSensitiveData(String(m as string));
|
||||
});
|
||||
|
||||
winstonLogger.log(level, stringMessages.join(" "));
|
||||
};
|
||||
|
||||
export const logger = {
|
||||
debug: (...messages: unknown[]) => log("debug", messages),
|
||||
info: (...messages: unknown[]) => log("info", messages),
|
||||
warn: (...messages: unknown[]) => log("warn", messages),
|
||||
error: (...messages: unknown[]) => log("error", messages),
|
||||
debug: (...messages: unknown[]) => consola.debug(formatMessages(messages).join(" ")),
|
||||
info: (...messages: unknown[]) => consola.info(formatMessages(messages).join(" ")),
|
||||
warn: (...messages: unknown[]) => consola.warn(formatMessages(messages).join(" ")),
|
||||
error: (...messages: unknown[]) => consola.error(formatMessages(messages).join(" ")),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ services:
|
|||
- .env.local
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- APP_SECRET=${APP_SECRET}
|
||||
- BASE_URL=${BASE_URL:-http://localhost:3000}
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
volumes:
|
||||
|
|
@ -44,6 +42,7 @@ services:
|
|||
environment:
|
||||
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
||||
- BASE_URL=http://localhost:4096
|
||||
- PORT=4096
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ export default defineConfig({
|
|||
"// @ts-nocheck",
|
||||
"// This file is auto-generated by @hey-api/openapi-ts",
|
||||
],
|
||||
postProcess: ["oxfmt"],
|
||||
postProcess: [
|
||||
"oxfmt",
|
||||
{ command: "bun", args: ["scripts/patch-api-client.ts"] },
|
||||
],
|
||||
},
|
||||
plugins: [...defaultPlugins, "@tanstack/react-query", "@hey-api/client-fetch"],
|
||||
});
|
||||
|
|
|
|||
31
package.json
31
package.json
|
|
@ -4,12 +4,13 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "oxlint --type-aware",
|
||||
"build": "react-router build",
|
||||
"dev": "NODE_ENV=development bunx --bun vite",
|
||||
"start": "bun ./dist/server/index.js",
|
||||
"build": "vite build",
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"preview": "bunx --bun vite preview",
|
||||
"cli:dev": "bun run app/server/cli/main.ts",
|
||||
"cli": "ZEROBYTE_CLI=1 bun ./dist/server/index.js",
|
||||
"tsc": "react-router typegen && tsc",
|
||||
"cli": "ZEROBYTE_CLI=1 bun .output/server/_ssr/index.mjs",
|
||||
"tsc": "tsc --noEmit",
|
||||
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
||||
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
|
||||
"start:e2e": "docker compose down && docker compose up --build zerobyte-e2e",
|
||||
|
|
@ -45,15 +46,19 @@
|
|||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-router/node": "^7.13.0",
|
||||
"@react-router/serve": "^7.13.0",
|
||||
"@scalar/hono-api-reference": "^0.9.38",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"@tanstack/react-router": "^1.157.18",
|
||||
"@tanstack/react-router-devtools": "^1.158.0",
|
||||
"@tanstack/react-router-ssr-query": "^1.157.18",
|
||||
"@tanstack/react-start": "^1.157.18",
|
||||
"arktype": "^2.1.28",
|
||||
"better-auth": "^1.4.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"consola": "^3.4.2",
|
||||
"cron-parser": "^5.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dither-plugin": "^1.1.1",
|
||||
|
|
@ -74,8 +79,6 @@
|
|||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.13.0",
|
||||
"react-router-hono-server": "^2.24.0",
|
||||
"recharts": "3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
|
|
@ -83,19 +86,17 @@
|
|||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tiny-typed-emitter": "^2.1.0",
|
||||
"winston": "^3.18.3",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@faker-js/faker": "^10.2.0",
|
||||
"@happy-dom/global-registrator": "^20.4.0",
|
||||
"@hey-api/openapi-ts": "^0.91.0",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@react-router/dev": "^7.13.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bun": "^1.3.8",
|
||||
|
|
@ -103,20 +104,22 @@
|
|||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"bun-types": "^1.3.9",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"drizzle-kit": "^1.0.0-beta.12-a5629fb",
|
||||
"lightningcss": "^1.31.1",
|
||||
"nitro": "^3.0.1-alpha.2",
|
||||
"oxfmt": "^0.27.0",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.3",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-bundle-analyzer": "^1.2.3",
|
||||
"vite-plugin-babel": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"wait-for-expect": "^4.0.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
buildDirectory: "dist",
|
||||
ssr: true,
|
||||
future: {
|
||||
v8_middleware: true,
|
||||
},
|
||||
} satisfies Config;
|
||||
50
scripts/patch-api-client.ts
Executable file
50
scripts/patch-api-client.ts
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-process script for openapi-ts generation.
|
||||
* Patches client.gen.ts to use request-scoped clients from server storage,
|
||||
* preventing cross-request cookie/origin leakage during SSR.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const CLIENT_GEN_PATH = resolve(process.cwd(), "app/client/api-client/client.gen.ts");
|
||||
|
||||
const PATCHED_CONTENT = `// @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";
|
||||
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
const fallbackClient = createClient(createConfig<ClientOptions2>({ 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
try {
|
||||
writeFileSync(CLIENT_GEN_PATH, PATCHED_CONTENT);
|
||||
console.log("✓ Patched client.gen.ts with request-scoped client support");
|
||||
} catch (error) {
|
||||
console.error("✗ Failed to patch client.gen.ts:", error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"types": ["node", "vite/client", "bun-types"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import { nitro } from "nitro/vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { reactRouterHonoServer } from "react-router-hono-server/dev";
|
||||
import babel from "vite-plugin-babel";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
reactRouterHonoServer({ runtime: "bun" }),
|
||||
reactRouter(),
|
||||
babel({
|
||||
filter: /\.[jt]sx?$/,
|
||||
babelConfig: {
|
||||
presets: ["@babel/preset-typescript"],
|
||||
plugins: [["babel-plugin-react-compiler"]],
|
||||
tsconfigPaths(),
|
||||
tanstackStart({
|
||||
srcDirectory: "app",
|
||||
router: {
|
||||
routesDirectory: "routes",
|
||||
},
|
||||
}),
|
||||
nitro({ preset: "bun" }),
|
||||
viteReact({
|
||||
babel: {
|
||||
plugins: ["babel-plugin-react-compiler"],
|
||||
},
|
||||
}),
|
||||
tailwindcss(),
|
||||
tsconfigPaths(),
|
||||
],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
|
|
|
|||
Loading…
Reference in a new issue