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:
Nico 2026-02-11 21:41:06 +01:00 committed by GitHub
parent b45d36e06a
commit 825d46c934
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 2496 additions and 1573 deletions

View file

@ -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/**

View file

@ -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
View file

@ -36,3 +36,4 @@ playwright/temp
# OpenAPI error logs
openapi-ts-error-*.log
.output

View file

@ -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"]

View file

@ -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);
}
},
});

View file

@ -1649,6 +1649,7 @@ export type ListSnapshotsResponses = {
size: number;
tags: Array<string>;
time: number;
hostname?: string;
}>;
};
@ -1718,6 +1719,7 @@ export type GetSnapshotDetailsResponses = {
size: number;
tags: Array<string>;
time: number;
hostname?: string;
};
};

View file

@ -1,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;

View file

@ -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" })}>

View file

@ -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 });

View file

@ -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}>

View file

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

View file

@ -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>
);

View file

@ -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({

View file

@ -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];

View file

@ -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) {

View file

@ -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" });
}
}
};

View file

@ -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";

View file

@ -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">

View file

@ -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" />

View file

@ -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);

View file

@ -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>

View file

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

View file

@ -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 }];
},
type Props = {
loaderData: {
schedule: BackupSchedule;
notifs: NotificationDestination[];
repos: Repository[];
scheduleNotifs: ScheduleNotification[];
mirrors: ScheduleMirror[];
};
scheduleId: string;
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Backup Job Details" },
{
name: "description",
content: "View and manage backup job configuration, schedule, and snapshots.",
},
];
}
export function ScheduleDetailsPage(props: Props) {
const { loaderData, scheduleId } = props;
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,
};
};
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
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

View file

@ -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

View file

@ -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

View file

@ -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}`}
/>
);
}

View file

@ -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}>

View file

@ -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", {

View file

@ -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>

View file

@ -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}>

View file

@ -1,8 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { Database, Plus, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { listRepositories } from "~/client/api-client/sdk.gen";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { Button } from "~/client/components/ui/button";
@ -10,31 +8,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>

View file

@ -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>
</>

View file

@ -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} />;
}

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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}>

View file

@ -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>

View file

@ -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>

View file

@ -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) => {

View file

@ -1,9 +0,0 @@
import { redirect } from "react-router";
export const clientLoader = async () => {
return redirect("/volumes");
};
export const loader = async () => {
return redirect("/volumes");
};

View file

@ -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
View 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));
}

View 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());
});

View file

@ -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();
});

View file

@ -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
View 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
View 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;
}
}

View file

@ -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;

View 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.",
},
],
}),
});

View 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.",
},
],
}),
});

View 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.",
},
],
}),
});

View file

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

View 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} />;
}

View 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 />;
}

View 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 />;
}

View 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} />;
}

View 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 />;
}

View 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 />;
}

View file

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

View 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} />;
}

View 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} />;
}

View 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 />;
}

View 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 />;
}

View 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} />;
}

View 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} />;
}

View 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} />;
}

View 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 />;
}

View 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
View 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
View 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
View 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
View 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);
}
});

View file

@ -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)

View file

@ -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,

View file

@ -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}`);
},
},
});

View file

@ -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(),
],
});
};

View file

@ -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", () => {

View file

@ -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();
}
});
});

View file

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

View file

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

View file

@ -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(" ")),
};

854
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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"],
});

View file

@ -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"
},

View file

@ -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
View 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);
}

View file

@ -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",

View file

@ -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",