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