From ffca433a43efa77407bca723f3a27d27b395dc1b Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 9 Nov 2025 13:04:14 +0200 Subject: [PATCH 1/7] fix: accessibility and responsiveness fixes --- .dockerignore | 7 +- apps/client/app/app.css | 285 ++++++++-------- apps/client/app/components/auth-layout.tsx | 48 +-- apps/client/app/components/layout.tsx | 135 ++++---- apps/client/app/components/onoff.tsx | 49 +-- apps/client/app/components/status-dot.tsx | 90 ++--- .../components/create-schedule-form.tsx | 2 +- .../routes/repository-details.tsx | 246 +++++++------- .../repositories/routes/snapshot-details.tsx | 162 ++++----- .../modules/volumes/routes/volume-details.tsx | 312 +++++++++--------- apps/client/app/root.tsx | 168 ++++++---- apps/client/vite.config.ts | 41 +-- 12 files changed, 820 insertions(+), 725 deletions(-) diff --git a/.dockerignore b/.dockerignore index 47deeba..f1df89d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -* +** !turbo.json !bun.lock @@ -20,6 +20,11 @@ !packages/**/src/** # License files and attributions + !LICENSE !NOTICES.md !LICENSES/** + +# Node modules + +**/node_modules/** diff --git a/apps/client/app/app.css b/apps/client/app/app.css index b7b1465..3901f0d 100644 --- a/apps/client/app/app.css +++ b/apps/client/app/app.css @@ -5,170 +5,171 @@ @custom-variant dark (&:is(.dark *)); @theme { - --font-sans: - "Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji"; + --breakpoint-xs: 32rem; + --font-sans: + "Google Sans Code", ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } html, body { - @apply bg-white dark:bg-[#131313]; - overflow-x: hidden; - width: 100%; - position: relative; - overscroll-behavior: none; - scrollbar-width: thin; + overflow-x: hidden; + width: 100%; + position: relative; + overscroll-behavior: none; + scrollbar-width: thin; +} - @media (prefers-color-scheme: dark) { - color-scheme: dark; - } +body { + @apply bg-[#131313]; + min-height: 100dvh; } .main-content { - scrollbar-width: thin; - scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-gutter: stable; } @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-card-header: var(--card-header); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - --color-strong-accent: var(--strong-accent); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-card-header: var(--card-header); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-strong-accent: var(--strong-accent); } :root { - color-scheme: dark; - - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --card-header: oklch(0.922 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); - --strong-accent: #ff543a; + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --card-header: oklch(0.922 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --strong-accent: #ff543a; } .dark { - --background: #131313; - --foreground: oklch(0.985 0 0); - --card: #131313; - --card-header: #1b1b1b; - /* --card: oklch(0.205 0 0); ORIGINAL */ - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.1448 0 0); - /* --secondary: oklch(0.269 0 0); ORIGINAL */ - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: #ff543a; - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); - --strong-accent: #ff543a; + color-scheme: dark; + + --background: #131313; + --foreground: oklch(0.985 0 0); + --card: #131313; + --card-header: #1b1b1b; + /* --card: oklch(0.205 0 0); ORIGINAL */ + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.1448 0 0); + /* --secondary: oklch(0.269 0 0); ORIGINAL */ + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: #ff543a; + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --strong-accent: #ff543a; } @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } @layer base { - :root { - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - } + :root { + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + } - .dark { - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - } + .dark { + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + } } diff --git a/apps/client/app/components/auth-layout.tsx b/apps/client/app/components/auth-layout.tsx index 63653bb..eb8b668 100644 --- a/apps/client/app/components/auth-layout.tsx +++ b/apps/client/app/components/auth-layout.tsx @@ -2,33 +2,33 @@ import { Mountain } from "lucide-react"; import type { ReactNode } from "react"; type AuthLayoutProps = { - title: string; - description: string; - children: ReactNode; + title: string; + description: string; + children: ReactNode; }; export function AuthLayout({ title, description, children }: AuthLayoutProps) { - return ( -
-
-
-
- - Ironmount -
+ return ( +
+
+
+
+ + Ironmount +
-
-

{title}

-

{description}

-
+
+

{title}

+

{description}

+
- {children} -
-
-
-
- ); + {children} +
+
+
+
+ ); } diff --git a/apps/client/app/components/layout.tsx b/apps/client/app/components/layout.tsx index fae5a9f..ca5cc29 100644 --- a/apps/client/app/components/layout.tsx +++ b/apps/client/app/components/layout.tsx @@ -15,73 +15,84 @@ import { AppSidebar } from "./app-sidebar"; export const clientMiddleware = [authMiddleware]; export async function clientLoader({ context }: Route.LoaderArgs) { - const ctx = context.get(appContext); + const ctx = context.get(appContext); - if (ctx.user && !ctx.user.hasDownloadedResticPassword) { - throw redirect("/download-recovery-key"); - } + if (ctx.user && !ctx.user.hasDownloadedResticPassword) { + throw redirect("/download-recovery-key"); + } - return ctx; + return ctx; } export default function Layout({ loaderData }: Route.ComponentProps) { - const navigate = useNavigate(); + const navigate = useNavigate(); - const logout = useMutation({ - ...logoutMutation(), - onSuccess: async () => { - navigate("/login", { replace: true }); - }, - onError: (error) => { - console.error(error); - toast.error("Logout failed", { description: error.message }); - }, - }); + const logout = useMutation({ + ...logoutMutation(), + onSuccess: async () => { + navigate("/login", { replace: true }); + }, + onError: (error) => { + console.error(error); + toast.error("Logout failed", { description: error.message }); + }, + }); - return ( - - -
-
-
-
- - -
- {loaderData.user && ( -
- - Welcome,  - {loaderData.user?.username} - - - -
- )} -
-
-
- -
- -
-
-
-
-
- ); + return ( + + +
+
+
+
+ + +
+ {loaderData.user && ( +
+ + Welcome,  + + {loaderData.user?.username} + + + + +
+ )} +
+
+
+ +
+ +
+
+
+
+
+ ); } diff --git a/apps/client/app/components/onoff.tsx b/apps/client/app/components/onoff.tsx index 1ee4a6f..d4100e2 100644 --- a/apps/client/app/components/onoff.tsx +++ b/apps/client/app/components/onoff.tsx @@ -2,25 +2,36 @@ import { cn } from "~/lib/utils"; import { Switch } from "./ui/switch"; type Props = { - isOn: boolean; - toggle: (v: boolean) => void; - enabledLabel: string; - disabledLabel: string; - disabled?: boolean; + isOn: boolean; + toggle: (v: boolean) => void; + enabledLabel: string; + disabledLabel: string; + disabled?: boolean; }; -export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: Props) => { - return ( -
- {isOn ? enabledLabel : disabledLabel} - -
- ); +export const OnOff = ({ + isOn, + toggle, + enabledLabel, + disabledLabel, + disabled, +}: Props) => { + return ( +
+ {isOn ? enabledLabel : disabledLabel} + +
+ ); }; diff --git a/apps/client/app/components/status-dot.tsx b/apps/client/app/components/status-dot.tsx index 721c29d..37c0439 100644 --- a/apps/client/app/components/status-dot.tsx +++ b/apps/client/app/components/status-dot.tsx @@ -3,47 +3,53 @@ import { cn } from "~/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; export const StatusDot = ({ status }: { status: VolumeStatus }) => { - const statusMapping = { - mounted: { - color: "bg-green-500", - colorLight: "bg-emerald-400", - animated: true, - }, - unmounted: { - color: "bg-gray-500", - colorLight: "bg-gray-400", - animated: false, - }, - error: { - color: "bg-red-500", - colorLight: "bg-amber-700", - animated: true, - }, - unknown: { - color: "bg-yellow-500", - colorLight: "bg-yellow-400", - animated: true, - }, - }[status]; + const statusMapping = { + mounted: { + color: "bg-green-500", + colorLight: "bg-emerald-400", + animated: true, + }, + unmounted: { + color: "bg-gray-500", + colorLight: "bg-gray-400", + animated: false, + }, + error: { + color: "bg-red-500", + colorLight: "bg-amber-700", + animated: true, + }, + unknown: { + color: "bg-yellow-500", + colorLight: "bg-yellow-400", + animated: true, + }, + }[status]; - return ( - - - - {statusMapping.animated && ( - - )} - - - - -

{status}

-
-
- ); + return ( + + + + {statusMapping.animated && ( + + )} + + + + +

{status}

+
+
+ ); }; diff --git a/apps/client/app/modules/backups/components/create-schedule-form.tsx b/apps/client/app/modules/backups/components/create-schedule-form.tsx index 498e10d..e72262f 100644 --- a/apps/client/app/modules/backups/components/create-schedule-form.tsx +++ b/apps/client/app/modules/backups/components/create-schedule-form.tsx @@ -232,7 +232,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: onSelectionChange={handleSelectionChange} withCheckboxes={true} foldersOnly={true} - className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]" + className="max-w-2xs xs:max-w-screen flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]" /> {selectedPaths.size > 0 && (
diff --git a/apps/client/app/modules/repositories/routes/repository-details.tsx b/apps/client/app/modules/repositories/routes/repository-details.tsx index 61f9ec3..e00ab92 100644 --- a/apps/client/app/modules/repositories/routes/repository-details.tsx +++ b/apps/client/app/modules/repositories/routes/repository-details.tsx @@ -3,19 +3,19 @@ import { useNavigate, useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useState, useEffect } from "react"; import { - deleteRepositoryMutation, - getRepositoryOptions, - listSnapshotsOptions, + deleteRepositoryMutation, + getRepositoryOptions, + listSnapshotsOptions, } from "~/api-client/@tanstack/react-query.gen"; import { Button } from "~/components/ui/button"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, } from "~/components/ui/alert-dialog"; import { parseError } from "~/lib/errors"; import { getRepository } from "~/api-client/sdk.gen"; @@ -26,127 +26,137 @@ import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; export function meta({ params }: Route.MetaArgs) { - return [ - { title: params.name }, - { - name: "description", - content: "View repository configuration, status, and snapshots.", - }, - ]; + return [ + { title: params.name }, + { + name: "description", + content: "View repository configuration, status, and snapshots.", + }, + ]; } export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { - const repository = await getRepository({ path: { name: params.name ?? "" } }); - if (repository.data) return repository.data; + const repository = await getRepository({ path: { name: params.name ?? "" } }); + if (repository.data) return repository.data; }; -export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) { - const { name } = useParams<{ name: string }>(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); +export default function RepositoryDetailsPage({ + loaderData, +}: Route.ComponentProps) { + const { name } = useParams<{ name: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = searchParams.get("tab") || "info"; + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get("tab") || "info"; - const { data } = useQuery({ - ...getRepositoryOptions({ path: { name: name ?? "" } }), - initialData: loaderData, - refetchInterval: 10000, - refetchOnWindowFocus: true, - }); + const { data } = useQuery({ + ...getRepositoryOptions({ path: { name: name ?? "" } }), + initialData: loaderData, + refetchInterval: 10000, + refetchOnWindowFocus: true, + }); - useEffect(() => { - if (name) { - queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } })); - } - }, [name, queryClient]); + useEffect(() => { + if (name) { + queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } })); + } + }, [name, queryClient]); - const deleteRepo = useMutation({ - ...deleteRepositoryMutation(), - onSuccess: () => { - toast.success("Repository deleted successfully"); - navigate("/repositories"); - }, - onError: (error) => { - toast.error("Failed to delete repository", { - description: parseError(error)?.message, - }); - }, - }); + const deleteRepo = useMutation({ + ...deleteRepositoryMutation(), + onSuccess: () => { + toast.success("Repository deleted successfully"); + navigate("/repositories"); + }, + onError: (error) => { + toast.error("Failed to delete repository", { + description: parseError(error)?.message, + }); + }, + }); - const handleConfirmDelete = () => { - setShowDeleteConfirm(false); - deleteRepo.mutate({ path: { name: name ?? "" } }); - }; + const handleConfirmDelete = () => { + setShowDeleteConfirm(false); + deleteRepo.mutate({ path: { name: name ?? "" } }); + }; - if (!name) { - return
Repository not found
; - } + if (!name) { + return
Repository not found
; + } - if (!data) { - return
Loading...
; - } + if (!data) { + return
Loading...
; + } - return ( - <> -
-
-
- - {data.status || "unknown"} - - {data.type} -
-
-
- -
-
+ return ( + <> +
+
+ + {data.status || "unknown"} + + + {data.type} + +
+
+ +
+
- setSearchParams({ tab: value })}> - - Configuration - Snapshots - - - - - - - - + setSearchParams({ tab: value })} + > + + Configuration + Snapshots + + + + + + + + - - - - Delete repository? - - Are you sure you want to delete the repository {name}? This action cannot be undone and - will remove all backup data. - - -
- Cancel - - Delete repository - -
-
-
- - ); + + + + Delete repository? + + Are you sure you want to delete the repository{" "} + {name}? This action cannot be undone and will + remove all backup data. + + +
+ Cancel + + Delete repository + +
+
+
+ + ); } diff --git a/apps/client/app/modules/repositories/routes/snapshot-details.tsx b/apps/client/app/modules/repositories/routes/snapshot-details.tsx index 3624b9f..3776768 100644 --- a/apps/client/app/modules/repositories/routes/snapshot-details.tsx +++ b/apps/client/app/modules/repositories/routes/snapshot-details.tsx @@ -8,90 +8,102 @@ import { getSnapshotDetails } from "~/api-client"; import type { Route } from "./+types/snapshot-details"; export function meta({ params }: Route.MetaArgs) { - return [ - { title: `Snapshot ${params.snapshotId}` }, - { - name: "description", - content: "Browse and restore files from a backup snapshot.", - }, - ]; + return [ + { title: `Snapshot ${params.snapshotId}` }, + { + name: "description", + content: "Browse and restore files from a backup snapshot.", + }, + ]; } export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { - const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } }); - if (snapshot.data) return snapshot.data; + const snapshot = await getSnapshotDetails({ + path: { name: params.name, snapshotId: params.snapshotId }, + }); + if (snapshot.data) return snapshot.data; - return redirect("/repositories"); + return redirect("/repositories"); }; -export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) { - const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>(); +export default function SnapshotDetailsPage({ + loaderData, +}: Route.ComponentProps) { + const { name, snapshotId } = useParams<{ + name: string; + snapshotId: string; + }>(); - const { data } = useQuery({ - ...listSnapshotFilesOptions({ - path: { name: name ?? "", snapshotId: snapshotId ?? "" }, - query: { path: "/" }, - }), - enabled: !!name && !!snapshotId, - }); + const { data } = useQuery({ + ...listSnapshotFilesOptions({ + path: { name: name ?? "", snapshotId: snapshotId ?? "" }, + query: { path: "/" }, + }), + enabled: !!name && !!snapshotId, + }); - if (!name || !snapshotId) { - return ( -
-

Invalid snapshot reference

-
- ); - } + if (!name || !snapshotId) { + return ( +
+

Invalid snapshot reference

+
+ ); + } - return ( -
-
-
-

{name}

-

Snapshot: {snapshotId}

-
- -
+ return ( +
+
+
+

{name}

+

+ Snapshot: {snapshotId} +

+
+ +
- + - {data?.snapshot && ( - - - Snapshot Information - - -
-
- Snapshot ID: -

{data.snapshot.id}

-
-
- Short ID: -

{data.snapshot.short_id}

-
-
- Hostname: -

{data.snapshot.hostname}

-
-
- Time: -

{new Date(data.snapshot.time).toLocaleString()}

-
-
- Paths: -
- {data.snapshot.paths.map((path) => ( -

- {path} -

- ))} -
-
-
-
-
- )} -
- ); + {data?.snapshot && ( + + + Snapshot Information + + +
+
+ Snapshot ID: +

{data.snapshot.id}

+
+
+ Short ID: +

{data.snapshot.short_id}

+
+
+ Hostname: +

{data.snapshot.hostname}

+
+
+ Time: +

{new Date(data.snapshot.time).toLocaleString()}

+
+
+ Paths: +
+ {data.snapshot.paths.map((path) => ( +

+ {path} +

+ ))} +
+
+
+
+
+ )} +
+ ); } diff --git a/apps/client/app/modules/volumes/routes/volume-details.tsx b/apps/client/app/modules/volumes/routes/volume-details.tsx index edf3169..cc19a3e 100644 --- a/apps/client/app/modules/volumes/routes/volume-details.tsx +++ b/apps/client/app/modules/volumes/routes/volume-details.tsx @@ -3,23 +3,23 @@ import { useNavigate, useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useState } from "react"; import { - deleteVolumeMutation, - getVolumeOptions, - getSystemInfoOptions, - mountVolumeMutation, - unmountVolumeMutation, + deleteVolumeMutation, + getVolumeOptions, + getSystemInfoOptions, + mountVolumeMutation, + unmountVolumeMutation, } from "~/api-client/@tanstack/react-query.gen"; import { StatusDot } from "~/components/status-dot"; import { Button } from "~/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, } from "~/components/ui/alert-dialog"; import { VolumeIcon } from "~/components/volume-icon"; import { parseError } from "~/lib/errors"; @@ -31,161 +31,169 @@ import { FilesTabContent } from "../tabs/files"; import { DockerTabContent } from "../tabs/docker"; export function meta({ params }: Route.MetaArgs) { - return [ - { title: params.name }, - { - name: "description", - content: "View and manage volume details, configuration, and files.", - }, - ]; + return [ + { title: params.name }, + { + name: "description", + content: "View and manage volume details, configuration, and files.", + }, + ]; } export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { - const volume = await getVolume({ path: { name: params.name ?? "" } }); - if (volume.data) return volume.data; + const volume = await getVolume({ path: { name: params.name ?? "" } }); + if (volume.data) return volume.data; }; export default function VolumeDetails({ loaderData }: Route.ComponentProps) { - const { name } = useParams<{ name: string }>(); - const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = searchParams.get("tab") || "info"; - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const { name } = useParams<{ name: string }>(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get("tab") || "info"; + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const { data } = useQuery({ - ...getVolumeOptions({ path: { name: name ?? "" } }), - initialData: loaderData, - refetchInterval: 10000, - refetchOnWindowFocus: true, - }); + const { data } = useQuery({ + ...getVolumeOptions({ path: { name: name ?? "" } }), + initialData: loaderData, + refetchInterval: 10000, + refetchOnWindowFocus: true, + }); - const { data: systemInfo } = useQuery({ - ...getSystemInfoOptions(), - }); + const { data: systemInfo } = useQuery({ + ...getSystemInfoOptions(), + }); - const deleteVol = useMutation({ - ...deleteVolumeMutation(), - onSuccess: () => { - toast.success("Volume deleted successfully"); - navigate("/volumes"); - }, - onError: (error) => { - toast.error("Failed to delete volume", { - description: parseError(error)?.message, - }); - }, - }); + const deleteVol = useMutation({ + ...deleteVolumeMutation(), + onSuccess: () => { + toast.success("Volume deleted successfully"); + navigate("/volumes"); + }, + onError: (error) => { + toast.error("Failed to delete volume", { + description: parseError(error)?.message, + }); + }, + }); - const mountVol = useMutation({ - ...mountVolumeMutation(), - onSuccess: () => { - toast.success("Volume mounted successfully"); - }, - onError: (error) => { - toast.error("Failed to mount volume", { - description: parseError(error)?.message, - }); - }, - }); + const mountVol = useMutation({ + ...mountVolumeMutation(), + onSuccess: () => { + toast.success("Volume mounted successfully"); + }, + onError: (error) => { + toast.error("Failed to mount volume", { + description: parseError(error)?.message, + }); + }, + }); - const unmountVol = useMutation({ - ...unmountVolumeMutation(), - onSuccess: () => { - toast.success("Volume unmounted successfully"); - }, - onError: (error) => { - toast.error("Failed to unmount volume", { - description: parseError(error)?.message, - }); - }, - }); + const unmountVol = useMutation({ + ...unmountVolumeMutation(), + onSuccess: () => { + toast.success("Volume unmounted successfully"); + }, + onError: (error) => { + toast.error("Failed to unmount volume", { + description: parseError(error)?.message, + }); + }, + }); - const handleConfirmDelete = () => { - setShowDeleteConfirm(false); - deleteVol.mutate({ path: { name: name ?? "" } }); - }; + const handleConfirmDelete = () => { + setShowDeleteConfirm(false); + deleteVol.mutate({ path: { name: name ?? "" } }); + }; - if (!name) { - return
Volume not found
; - } + if (!name) { + return
Volume not found
; + } - if (!data) { - return
Loading...
; - } + if (!data) { + return
Loading...
; + } - const { volume, statfs } = data; - const dockerAvailable = systemInfo?.capabilities?.docker ?? false; + const { volume, statfs } = data; + const dockerAvailable = systemInfo?.capabilities?.docker ?? false; - return ( - <> -
-
-
- - {volume.status[0].toUpperCase() + volume.status.slice(1)} - - -
-
-
- - - -
-
- setSearchParams({ tab: value })} className="mt-4"> - - Configuration - Files - {dockerAvailable && Docker} - - - - - - - - {dockerAvailable && ( - - - - )} - + return ( + <> +
+
+ + {" "} + {volume.status[0].toUpperCase() + volume.status.slice(1)} + + +
+
+ + + +
+
+ setSearchParams({ tab: value })} + className="mt-4" + > + + Configuration + Files + {dockerAvailable && Docker} + + + + + + + + {dockerAvailable && ( + + + + )} + - - - - Delete volume? - - Are you sure you want to delete the volume {name}? This action cannot be undone. - - -
- Cancel - - Delete volume - -
-
-
- - ); + + + + Delete volume? + + Are you sure you want to delete the volume {name} + ? This action cannot be undone. + + +
+ Cancel + + Delete volume + +
+
+
+ + ); } diff --git a/apps/client/app/root.tsx b/apps/client/app/root.tsx index 7b238c4..2058fc0 100644 --- a/apps/client/app/root.tsx +++ b/apps/client/app/root.tsx @@ -1,5 +1,16 @@ -import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; +import { + MutationCache, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; import { Toaster } from "~/components/ui/sonner"; import type { Route } from "./+types/root"; @@ -8,89 +19,108 @@ import { client } from "./api-client/client.gen"; import { useServerEvents } from "./hooks/use-server-events"; client.setConfig({ - baseUrl: "/", + 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", - }, + { 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: () => { - queryClient.invalidateQueries(); - }, - onError: (error) => { - console.error("Mutation error:", error); - queryClient.invalidateQueries(); - }, - }), + mutationCache: new MutationCache({ + onSuccess: () => { + queryClient.invalidateQueries(); + }, + onError: (error) => { + console.error("Mutation error:", error); + queryClient.invalidateQueries(); + }, + }), }); export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - - - - - - - - {children} - - - - - - - ); + return ( + + + + + + + + + + + + + + + + {children} + + + + + + + ); } export default function App() { - useServerEvents(); + useServerEvents(); - return ; + return ; } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - let message = "Oops!"; - let details = "An unexpected error occurred."; - let stack: string | undefined; + 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; - } + 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 ( -
-

{message}

-

{details}

- {stack && ( -
-					{stack}
-				
- )} -
- ); + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); } diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index 988d817..c4096c6 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -7,27 +7,28 @@ const alias = {}; const { NODE_ENV } = process.env; if (NODE_ENV === "production") { - // @ts-expect-error - alias["react-dom/server"] = "react-dom/server.node"; + // @ts-expect-error + alias["react-dom/server"] = "react-dom/server.node"; } export default defineConfig({ - plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], - resolve: { - alias, - }, - build: { - outDir: "dist", - // sourcemap: true, - }, - server: { - host: true, - port: 4097, - proxy: { - "/api": { - target: "http://localhost:4096", - changeOrigin: true, - }, - }, - }, + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], + resolve: { + alias, + }, + build: { + outDir: "dist", + // sourcemap: true, + }, + server: { + host: true, + port: 4097, + proxy: { + "/api": { + target: "http://localhost:4096", + changeOrigin: true, + }, + }, + allowedHosts: true, + }, }); From db0d153610accb362cb3ee1168b25163826b0cfd Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 9 Nov 2025 13:16:14 +0200 Subject: [PATCH 2/7] chore: format --- apps/client/app/app.css | 280 ++++++++-------- apps/client/app/components/auth-layout.tsx | 48 +-- apps/client/app/components/layout.tsx | 135 ++++---- apps/client/app/components/onoff.tsx | 54 ++- apps/client/app/components/status-dot.tsx | 93 +++--- .../routes/repository-details.tsx | 241 +++++++------- .../repositories/routes/snapshot-details.tsx | 167 +++++----- .../modules/volumes/routes/volume-details.tsx | 310 +++++++++--------- apps/client/app/root.tsx | 168 ++++------ apps/client/vite.config.ts | 42 +-- 10 files changed, 728 insertions(+), 810 deletions(-) diff --git a/apps/client/app/app.css b/apps/client/app/app.css index 3901f0d..c12eee5 100644 --- a/apps/client/app/app.css +++ b/apps/client/app/app.css @@ -5,171 +5,171 @@ @custom-variant dark (&:is(.dark *)); @theme { - --breakpoint-xs: 32rem; - --font-sans: - "Google Sans Code", ui-sans-serif, system-ui, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --breakpoint-xs: 32rem; + --font-sans: + "Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; } html, body { - overflow-x: hidden; - width: 100%; - position: relative; - overscroll-behavior: none; - scrollbar-width: thin; + overflow-x: hidden; + width: 100%; + position: relative; + overscroll-behavior: none; + scrollbar-width: thin; } body { - @apply bg-[#131313]; - min-height: 100dvh; + @apply bg-[#131313]; + min-height: 100dvh; } .main-content { - scrollbar-width: thin; - scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-gutter: stable; } @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-card-header: var(--card-header); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - --color-strong-accent: var(--strong-accent); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-card-header: var(--card-header); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-strong-accent: var(--strong-accent); } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --card-header: oklch(0.922 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); - --strong-accent: #ff543a; + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --card-header: oklch(0.922 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --strong-accent: #ff543a; } .dark { - color-scheme: dark; + color-scheme: dark; - --background: #131313; - --foreground: oklch(0.985 0 0); - --card: #131313; - --card-header: #1b1b1b; - /* --card: oklch(0.205 0 0); ORIGINAL */ - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.1448 0 0); - /* --secondary: oklch(0.269 0 0); ORIGINAL */ - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: #ff543a; - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); - --strong-accent: #ff543a; + --background: #131313; + --foreground: oklch(0.985 0 0); + --card: #131313; + --card-header: #1b1b1b; + /* --card: oklch(0.205 0 0); ORIGINAL */ + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.1448 0 0); + /* --secondary: oklch(0.269 0 0); ORIGINAL */ + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: #ff543a; + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --strong-accent: #ff543a; } @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } @layer base { - :root { - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - } + :root { + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + } - .dark { - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - } + .dark { + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + } } diff --git a/apps/client/app/components/auth-layout.tsx b/apps/client/app/components/auth-layout.tsx index eb8b668..2435a41 100644 --- a/apps/client/app/components/auth-layout.tsx +++ b/apps/client/app/components/auth-layout.tsx @@ -2,33 +2,33 @@ import { Mountain } from "lucide-react"; import type { ReactNode } from "react"; type AuthLayoutProps = { - title: string; - description: string; - children: ReactNode; + title: string; + description: string; + children: ReactNode; }; export function AuthLayout({ title, description, children }: AuthLayoutProps) { - return ( -
-
-
-
- - Ironmount -
+ return ( +
+
+
+
+ + Ironmount +
-
-

{title}

-

{description}

-
+
+

{title}

+

{description}

+
- {children} -
-
-
-
- ); + {children} +
+
+
+
+ ); } diff --git a/apps/client/app/components/layout.tsx b/apps/client/app/components/layout.tsx index ca5cc29..70caebb 100644 --- a/apps/client/app/components/layout.tsx +++ b/apps/client/app/components/layout.tsx @@ -15,84 +15,73 @@ import { AppSidebar } from "./app-sidebar"; export const clientMiddleware = [authMiddleware]; export async function clientLoader({ context }: Route.LoaderArgs) { - const ctx = context.get(appContext); + const ctx = context.get(appContext); - if (ctx.user && !ctx.user.hasDownloadedResticPassword) { - throw redirect("/download-recovery-key"); - } + if (ctx.user && !ctx.user.hasDownloadedResticPassword) { + throw redirect("/download-recovery-key"); + } - return ctx; + return ctx; } export default function Layout({ loaderData }: Route.ComponentProps) { - const navigate = useNavigate(); + const navigate = useNavigate(); - const logout = useMutation({ - ...logoutMutation(), - onSuccess: async () => { - navigate("/login", { replace: true }); - }, - onError: (error) => { - console.error(error); - toast.error("Logout failed", { description: error.message }); - }, - }); + const logout = useMutation({ + ...logoutMutation(), + onSuccess: async () => { + navigate("/login", { replace: true }); + }, + onError: (error) => { + console.error(error); + toast.error("Logout failed", { description: error.message }); + }, + }); - return ( - - -
-
-
-
- - -
- {loaderData.user && ( -
- - Welcome,  - - {loaderData.user?.username} - - - - -
- )} -
-
-
- -
- -
-
-
-
-
- ); + return ( + + +
+
+
+
+ + +
+ {loaderData.user && ( +
+ + Welcome,  + {loaderData.user?.username} + + + +
+ )} +
+
+
+ +
+ +
+
+
+
+
+ ); } diff --git a/apps/client/app/components/onoff.tsx b/apps/client/app/components/onoff.tsx index d4100e2..a5b690d 100644 --- a/apps/client/app/components/onoff.tsx +++ b/apps/client/app/components/onoff.tsx @@ -2,36 +2,30 @@ import { cn } from "~/lib/utils"; import { Switch } from "./ui/switch"; type Props = { - isOn: boolean; - toggle: (v: boolean) => void; - enabledLabel: string; - disabledLabel: string; - disabled?: boolean; + isOn: boolean; + toggle: (v: boolean) => void; + enabledLabel: string; + disabledLabel: string; + disabled?: boolean; }; -export const OnOff = ({ - isOn, - toggle, - enabledLabel, - disabledLabel, - disabled, -}: Props) => { - return ( -
- {isOn ? enabledLabel : disabledLabel} - -
- ); +export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: Props) => { + return ( +
+ {isOn ? enabledLabel : disabledLabel} + +
+ ); }; diff --git a/apps/client/app/components/status-dot.tsx b/apps/client/app/components/status-dot.tsx index 37c0439..9206a33 100644 --- a/apps/client/app/components/status-dot.tsx +++ b/apps/client/app/components/status-dot.tsx @@ -3,53 +3,50 @@ import { cn } from "~/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; export const StatusDot = ({ status }: { status: VolumeStatus }) => { - const statusMapping = { - mounted: { - color: "bg-green-500", - colorLight: "bg-emerald-400", - animated: true, - }, - unmounted: { - color: "bg-gray-500", - colorLight: "bg-gray-400", - animated: false, - }, - error: { - color: "bg-red-500", - colorLight: "bg-amber-700", - animated: true, - }, - unknown: { - color: "bg-yellow-500", - colorLight: "bg-yellow-400", - animated: true, - }, - }[status]; + const statusMapping = { + mounted: { + color: "bg-green-500", + colorLight: "bg-emerald-400", + animated: true, + }, + unmounted: { + color: "bg-gray-500", + colorLight: "bg-gray-400", + animated: false, + }, + error: { + color: "bg-red-500", + colorLight: "bg-amber-700", + animated: true, + }, + unknown: { + color: "bg-yellow-500", + colorLight: "bg-yellow-400", + animated: true, + }, + }[status]; - return ( - - - - {statusMapping.animated && ( - - )} - - - - -

{status}

-
-
- ); + return ( + + + + {statusMapping.animated && ( + + )} + + + + +

{status}

+
+
+ ); }; diff --git a/apps/client/app/modules/repositories/routes/repository-details.tsx b/apps/client/app/modules/repositories/routes/repository-details.tsx index e00ab92..dc15d17 100644 --- a/apps/client/app/modules/repositories/routes/repository-details.tsx +++ b/apps/client/app/modules/repositories/routes/repository-details.tsx @@ -3,19 +3,19 @@ import { useNavigate, useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useState, useEffect } from "react"; import { - deleteRepositoryMutation, - getRepositoryOptions, - listSnapshotsOptions, + deleteRepositoryMutation, + getRepositoryOptions, + listSnapshotsOptions, } from "~/api-client/@tanstack/react-query.gen"; import { Button } from "~/components/ui/button"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, } from "~/components/ui/alert-dialog"; import { parseError } from "~/lib/errors"; import { getRepository } from "~/api-client/sdk.gen"; @@ -26,137 +26,122 @@ import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; export function meta({ params }: Route.MetaArgs) { - return [ - { title: params.name }, - { - name: "description", - content: "View repository configuration, status, and snapshots.", - }, - ]; + return [ + { title: params.name }, + { + name: "description", + content: "View repository configuration, status, and snapshots.", + }, + ]; } export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { - const repository = await getRepository({ path: { name: params.name ?? "" } }); - if (repository.data) return repository.data; + const repository = await getRepository({ path: { name: params.name ?? "" } }); + if (repository.data) return repository.data; }; -export default function RepositoryDetailsPage({ - loaderData, -}: Route.ComponentProps) { - const { name } = useParams<{ name: string }>(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); +export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) { + const { name } = useParams<{ name: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = searchParams.get("tab") || "info"; + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get("tab") || "info"; - const { data } = useQuery({ - ...getRepositoryOptions({ path: { name: name ?? "" } }), - initialData: loaderData, - refetchInterval: 10000, - refetchOnWindowFocus: true, - }); + const { data } = useQuery({ + ...getRepositoryOptions({ path: { name: name ?? "" } }), + initialData: loaderData, + refetchInterval: 10000, + refetchOnWindowFocus: true, + }); - useEffect(() => { - if (name) { - queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } })); - } - }, [name, queryClient]); + useEffect(() => { + if (name) { + queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } })); + } + }, [name, queryClient]); - const deleteRepo = useMutation({ - ...deleteRepositoryMutation(), - onSuccess: () => { - toast.success("Repository deleted successfully"); - navigate("/repositories"); - }, - onError: (error) => { - toast.error("Failed to delete repository", { - description: parseError(error)?.message, - }); - }, - }); + const deleteRepo = useMutation({ + ...deleteRepositoryMutation(), + onSuccess: () => { + toast.success("Repository deleted successfully"); + navigate("/repositories"); + }, + onError: (error) => { + toast.error("Failed to delete repository", { + description: parseError(error)?.message, + }); + }, + }); - const handleConfirmDelete = () => { - setShowDeleteConfirm(false); - deleteRepo.mutate({ path: { name: name ?? "" } }); - }; + const handleConfirmDelete = () => { + setShowDeleteConfirm(false); + deleteRepo.mutate({ path: { name: name ?? "" } }); + }; - if (!name) { - return
Repository not found
; - } + if (!name) { + return
Repository not found
; + } - if (!data) { - return
Loading...
; - } + if (!data) { + return
Loading...
; + } - return ( - <> -
-
- - {data.status || "unknown"} - - - {data.type} - -
-
- -
-
+ return ( + <> +
+
+ + {data.status || "unknown"} + + {data.type} +
+
+ +
+
- setSearchParams({ tab: value })} - > - - Configuration - Snapshots - - - - - - - - + setSearchParams({ tab: value })}> + + Configuration + Snapshots + + + + + + + + - - - - Delete repository? - - Are you sure you want to delete the repository{" "} - {name}? This action cannot be undone and will - remove all backup data. - - -
- Cancel - - Delete repository - -
-
-
- - ); + + + + Delete repository? + + Are you sure you want to delete the repository {name}? This action cannot be undone and + will remove all backup data. + + +
+ Cancel + + Delete repository + +
+
+
+ + ); } diff --git a/apps/client/app/modules/repositories/routes/snapshot-details.tsx b/apps/client/app/modules/repositories/routes/snapshot-details.tsx index 3776768..f2255ec 100644 --- a/apps/client/app/modules/repositories/routes/snapshot-details.tsx +++ b/apps/client/app/modules/repositories/routes/snapshot-details.tsx @@ -8,102 +8,95 @@ import { getSnapshotDetails } from "~/api-client"; import type { Route } from "./+types/snapshot-details"; export function meta({ params }: Route.MetaArgs) { - return [ - { title: `Snapshot ${params.snapshotId}` }, - { - name: "description", - content: "Browse and restore files from a backup snapshot.", - }, - ]; + return [ + { title: `Snapshot ${params.snapshotId}` }, + { + name: "description", + content: "Browse and restore files from a backup snapshot.", + }, + ]; } export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { - const snapshot = await getSnapshotDetails({ - path: { name: params.name, snapshotId: params.snapshotId }, - }); - if (snapshot.data) return snapshot.data; + const snapshot = await getSnapshotDetails({ + path: { name: params.name, snapshotId: params.snapshotId }, + }); + if (snapshot.data) return snapshot.data; - return redirect("/repositories"); + return redirect("/repositories"); }; -export default function SnapshotDetailsPage({ - loaderData, -}: Route.ComponentProps) { - const { name, snapshotId } = useParams<{ - name: string; - snapshotId: string; - }>(); +export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) { + const { name, snapshotId } = useParams<{ + name: string; + snapshotId: string; + }>(); - const { data } = useQuery({ - ...listSnapshotFilesOptions({ - path: { name: name ?? "", snapshotId: snapshotId ?? "" }, - query: { path: "/" }, - }), - enabled: !!name && !!snapshotId, - }); + const { data } = useQuery({ + ...listSnapshotFilesOptions({ + path: { name: name ?? "", snapshotId: snapshotId ?? "" }, + query: { path: "/" }, + }), + enabled: !!name && !!snapshotId, + }); - if (!name || !snapshotId) { - return ( -
-

Invalid snapshot reference

-
- ); - } + if (!name || !snapshotId) { + return ( +
+

Invalid snapshot reference

+
+ ); + } - return ( -
-
-
-

{name}

-

- Snapshot: {snapshotId} -

-
- -
+ return ( +
+
+
+

{name}

+

Snapshot: {snapshotId}

+
+ +
- + - {data?.snapshot && ( - - - Snapshot Information - - -
-
- Snapshot ID: -

{data.snapshot.id}

-
-
- Short ID: -

{data.snapshot.short_id}

-
-
- Hostname: -

{data.snapshot.hostname}

-
-
- Time: -

{new Date(data.snapshot.time).toLocaleString()}

-
-
- Paths: -
- {data.snapshot.paths.map((path) => ( -

- {path} -

- ))} -
-
-
-
-
- )} -
- ); + {data?.snapshot && ( + + + Snapshot Information + + +
+
+ Snapshot ID: +

{data.snapshot.id}

+
+
+ Short ID: +

{data.snapshot.short_id}

+
+
+ Hostname: +

{data.snapshot.hostname}

+
+
+ Time: +

{new Date(data.snapshot.time).toLocaleString()}

+
+
+ Paths: +
+ {data.snapshot.paths.map((path) => ( +

+ {path} +

+ ))} +
+
+
+
+
+ )} +
+ ); } diff --git a/apps/client/app/modules/volumes/routes/volume-details.tsx b/apps/client/app/modules/volumes/routes/volume-details.tsx index cc19a3e..7ac12a7 100644 --- a/apps/client/app/modules/volumes/routes/volume-details.tsx +++ b/apps/client/app/modules/volumes/routes/volume-details.tsx @@ -3,23 +3,23 @@ import { useNavigate, useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useState } from "react"; import { - deleteVolumeMutation, - getVolumeOptions, - getSystemInfoOptions, - mountVolumeMutation, - unmountVolumeMutation, + deleteVolumeMutation, + getVolumeOptions, + getSystemInfoOptions, + mountVolumeMutation, + unmountVolumeMutation, } from "~/api-client/@tanstack/react-query.gen"; import { StatusDot } from "~/components/status-dot"; import { Button } from "~/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, } from "~/components/ui/alert-dialog"; import { VolumeIcon } from "~/components/volume-icon"; import { parseError } from "~/lib/errors"; @@ -31,169 +31,159 @@ import { FilesTabContent } from "../tabs/files"; import { DockerTabContent } from "../tabs/docker"; export function meta({ params }: Route.MetaArgs) { - return [ - { title: params.name }, - { - name: "description", - content: "View and manage volume details, configuration, and files.", - }, - ]; + return [ + { title: params.name }, + { + name: "description", + content: "View and manage volume details, configuration, and files.", + }, + ]; } export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { - const volume = await getVolume({ path: { name: params.name ?? "" } }); - if (volume.data) return volume.data; + const volume = await getVolume({ path: { name: params.name ?? "" } }); + if (volume.data) return volume.data; }; export default function VolumeDetails({ loaderData }: Route.ComponentProps) { - const { name } = useParams<{ name: string }>(); - const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = searchParams.get("tab") || "info"; - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const { name } = useParams<{ name: string }>(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get("tab") || "info"; + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const { data } = useQuery({ - ...getVolumeOptions({ path: { name: name ?? "" } }), - initialData: loaderData, - refetchInterval: 10000, - refetchOnWindowFocus: true, - }); + const { data } = useQuery({ + ...getVolumeOptions({ path: { name: name ?? "" } }), + initialData: loaderData, + refetchInterval: 10000, + refetchOnWindowFocus: true, + }); - const { data: systemInfo } = useQuery({ - ...getSystemInfoOptions(), - }); + const { data: systemInfo } = useQuery({ + ...getSystemInfoOptions(), + }); - const deleteVol = useMutation({ - ...deleteVolumeMutation(), - onSuccess: () => { - toast.success("Volume deleted successfully"); - navigate("/volumes"); - }, - onError: (error) => { - toast.error("Failed to delete volume", { - description: parseError(error)?.message, - }); - }, - }); + const deleteVol = useMutation({ + ...deleteVolumeMutation(), + onSuccess: () => { + toast.success("Volume deleted successfully"); + navigate("/volumes"); + }, + onError: (error) => { + toast.error("Failed to delete volume", { + description: parseError(error)?.message, + }); + }, + }); - const mountVol = useMutation({ - ...mountVolumeMutation(), - onSuccess: () => { - toast.success("Volume mounted successfully"); - }, - onError: (error) => { - toast.error("Failed to mount volume", { - description: parseError(error)?.message, - }); - }, - }); + const mountVol = useMutation({ + ...mountVolumeMutation(), + onSuccess: () => { + toast.success("Volume mounted successfully"); + }, + onError: (error) => { + toast.error("Failed to mount volume", { + description: parseError(error)?.message, + }); + }, + }); - const unmountVol = useMutation({ - ...unmountVolumeMutation(), - onSuccess: () => { - toast.success("Volume unmounted successfully"); - }, - onError: (error) => { - toast.error("Failed to unmount volume", { - description: parseError(error)?.message, - }); - }, - }); + const unmountVol = useMutation({ + ...unmountVolumeMutation(), + onSuccess: () => { + toast.success("Volume unmounted successfully"); + }, + onError: (error) => { + toast.error("Failed to unmount volume", { + description: parseError(error)?.message, + }); + }, + }); - const handleConfirmDelete = () => { - setShowDeleteConfirm(false); - deleteVol.mutate({ path: { name: name ?? "" } }); - }; + const handleConfirmDelete = () => { + setShowDeleteConfirm(false); + deleteVol.mutate({ path: { name: name ?? "" } }); + }; - if (!name) { - return
Volume not found
; - } + if (!name) { + return
Volume not found
; + } - if (!data) { - return
Loading...
; - } + if (!data) { + return
Loading...
; + } - const { volume, statfs } = data; - const dockerAvailable = systemInfo?.capabilities?.docker ?? false; + const { volume, statfs } = data; + const dockerAvailable = systemInfo?.capabilities?.docker ?? false; - return ( - <> -
-
- - {" "} - {volume.status[0].toUpperCase() + volume.status.slice(1)} - - -
-
- - - -
-
- setSearchParams({ tab: value })} - className="mt-4" - > - - Configuration - Files - {dockerAvailable && Docker} - - - - - - - - {dockerAvailable && ( - - - - )} - + return ( + <> +
+
+ + {volume.status[0].toUpperCase() + volume.status.slice(1)} + + +
+
+ + + +
+
+ setSearchParams({ tab: value })} className="mt-4"> + + Configuration + Files + {dockerAvailable && Docker} + + + + + + + + {dockerAvailable && ( + + + + )} + - - - - Delete volume? - - Are you sure you want to delete the volume {name} - ? This action cannot be undone. - - -
- Cancel - - Delete volume - -
-
-
- - ); + + + + Delete volume? + + Are you sure you want to delete the volume {name}? This action cannot be undone. + + +
+ Cancel + + Delete volume + +
+
+
+ + ); } diff --git a/apps/client/app/root.tsx b/apps/client/app/root.tsx index 2058fc0..93c8133 100644 --- a/apps/client/app/root.tsx +++ b/apps/client/app/root.tsx @@ -1,16 +1,5 @@ -import { - MutationCache, - QueryClient, - QueryClientProvider, -} from "@tanstack/react-query"; -import { - isRouteErrorResponse, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, -} from "react-router"; +import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; import { Toaster } from "~/components/ui/sonner"; import type { Route } from "./+types/root"; @@ -19,108 +8,89 @@ import { client } from "./api-client/client.gen"; import { useServerEvents } from "./hooks/use-server-events"; client.setConfig({ - baseUrl: "/", + 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", - }, + { 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: () => { - queryClient.invalidateQueries(); - }, - onError: (error) => { - console.error("Mutation error:", error); - queryClient.invalidateQueries(); - }, - }), + mutationCache: new MutationCache({ + onSuccess: () => { + queryClient.invalidateQueries(); + }, + onError: (error) => { + console.error("Mutation error:", error); + queryClient.invalidateQueries(); + }, + }), }); export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - - - - - - - - {children} - - - - - - - ); + return ( + + + + + + + + + + + + + + + + {children} + + + + + + + ); } export default function App() { - useServerEvents(); + useServerEvents(); - return ; + return ; } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - let message = "Oops!"; - let details = "An unexpected error occurred."; - let stack: string | undefined; + 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; - } + 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 ( -
-

{message}

-

{details}

- {stack && ( -
-          {stack}
-        
- )} -
- ); + return ( +
+

{message}

+

{details}

+ {stack && ( +
+					{stack}
+				
+ )} +
+ ); } diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index c4096c6..9235c1f 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -7,28 +7,28 @@ const alias = {}; const { NODE_ENV } = process.env; if (NODE_ENV === "production") { - // @ts-expect-error - alias["react-dom/server"] = "react-dom/server.node"; + // @ts-expect-error + alias["react-dom/server"] = "react-dom/server.node"; } export default defineConfig({ - plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], - resolve: { - alias, - }, - build: { - outDir: "dist", - // sourcemap: true, - }, - server: { - host: true, - port: 4097, - proxy: { - "/api": { - target: "http://localhost:4096", - changeOrigin: true, - }, - }, - allowedHosts: true, - }, + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], + resolve: { + alias, + }, + build: { + outDir: "dist", + // sourcemap: true, + }, + server: { + host: true, + port: 4097, + proxy: { + "/api": { + target: "http://localhost:4096", + changeOrigin: true, + }, + }, + allowedHosts: true, + }, }); From 1152939373a5a0a23edc5a5a19ffe44837c711d6 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 9 Nov 2025 11:31:44 +0100 Subject: [PATCH 3/7] feat: repository doctor always visible --- .../routes/repository-details.tsx | 121 +++++++++-- .../app/modules/repositories/tabs/info.tsx | 200 ++++-------------- 2 files changed, 148 insertions(+), 173 deletions(-) diff --git a/apps/client/app/modules/repositories/routes/repository-details.tsx b/apps/client/app/modules/repositories/routes/repository-details.tsx index dc15d17..c2cf51a 100644 --- a/apps/client/app/modules/repositories/routes/repository-details.tsx +++ b/apps/client/app/modules/repositories/routes/repository-details.tsx @@ -1,9 +1,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate, useParams, useSearchParams } from "react-router"; +import { redirect, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { useState, useEffect } from "react"; import { deleteRepositoryMutation, + doctorRepositoryMutation, getRepositoryOptions, listSnapshotsOptions, } from "~/api-client/@tanstack/react-query.gen"; @@ -24,6 +25,7 @@ import { cn } from "~/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; +import { Loader2 } from "lucide-react"; export function meta({ params }: Route.MetaArgs) { return [ @@ -38,10 +40,13 @@ export function meta({ params }: Route.MetaArgs) { export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { const repository = await getRepository({ path: { name: params.name ?? "" } }); if (repository.data) return repository.data; + + return redirect("/repositories"); }; export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) { - const { name } = useParams<{ name: string }>(); + const [showDoctorResults, setShowDoctorResults] = useState(false); + const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -50,17 +55,15 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro const activeTab = searchParams.get("tab") || "info"; const { data } = useQuery({ - ...getRepositoryOptions({ path: { name: name ?? "" } }), + ...getRepositoryOptions({ path: { name: loaderData.name } }), initialData: loaderData, refetchInterval: 10000, refetchOnWindowFocus: true, }); useEffect(() => { - if (name) { - queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } })); - } - }, [name, queryClient]); + queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } })); + }, [queryClient, data.name]); const deleteRepo = useMutation({ ...deleteRepositoryMutation(), @@ -75,18 +78,48 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro }, }); + const doctorMutation = useMutation({ + ...doctorRepositoryMutation(), + onSuccess: (data) => { + if (data) { + setShowDoctorResults(true); + + if (data.success) { + toast.success("Repository doctor completed successfully"); + } else { + toast.warning("Doctor completed with some issues", { + description: "Check the details for more information", + richColors: true, + }); + } + } + }, + onError: (error) => { + toast.error("Failed to run doctor", { + description: parseError(error)?.message, + }); + }, + }); + const handleConfirmDelete = () => { setShowDeleteConfirm(false); - deleteRepo.mutate({ path: { name: name ?? "" } }); + deleteRepo.mutate({ path: { name: data.name } }); }; - if (!name) { - return
Repository not found
; - } - - if (!data) { - return
Loading...
; - } + const getStepLabel = (step: string) => { + switch (step) { + case "unlock": + return "Unlock Repository"; + case "check": + return "Check Repository"; + case "repair_index": + return "Repair Index"; + case "recheck": + return "Re-check Repository"; + default: + return step; + } + }; return ( <> @@ -103,6 +136,20 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro {data.type}
+ @@ -127,8 +174,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro Delete repository? - Are you sure you want to delete the repository {name}? This action cannot be undone and - will remove all backup data. + Are you sure you want to delete the repository {data.name}? This action cannot be undone + and will remove all backup data.
@@ -142,6 +189,46 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
+ + + + + Doctor Results + Repository doctor operation completed + + + {doctorMutation.data && ( +
+ {doctorMutation.data.steps.map((step) => ( +
+
+ {getStepLabel(step.step)} + + {step.success ? "Success" : "Warning"} + +
+ {step.error &&

{step.error}

} +
+ ))} +
+ )} + +
+ +
+
+
); } diff --git a/apps/client/app/modules/repositories/tabs/info.tsx b/apps/client/app/modules/repositories/tabs/info.tsx index 0dd3f42..d2d8507 100644 --- a/apps/client/app/modules/repositories/tabs/info.tsx +++ b/apps/client/app/modules/repositories/tabs/info.tsx @@ -1,175 +1,63 @@ -import { useMutation } from "@tanstack/react-query"; -import { useState } from "react"; -import { toast } from "sonner"; import { Card } from "~/components/ui/card"; -import { Button } from "~/components/ui/button"; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, -} from "~/components/ui/alert-dialog"; -import { Loader2 } from "lucide-react"; import type { Repository } from "~/lib/types"; -import { parseError } from "~/lib/errors"; -import { doctorRepositoryMutation } from "~/api-client/@tanstack/react-query.gen"; -import { cn } from "~/lib/utils"; type Props = { repository: Repository; }; export const RepositoryInfoTabContent = ({ repository }: Props) => { - const [showDoctorResults, setShowDoctorResults] = useState(false); - - const doctorMutation = useMutation({ - ...doctorRepositoryMutation(), - onSuccess: (data) => { - if (data) { - setShowDoctorResults(true); - - if (data.success) { - toast.success("Repository doctor completed successfully"); - } else { - toast.warning("Doctor completed with some issues", { - description: "Check the details for more information", - richColors: true, - }); - } - } - }, - onError: (error) => { - toast.error("Failed to run doctor", { - description: parseError(error)?.message, - }); - }, - }); - - const handleDoctor = () => { - doctorMutation.mutate({ path: { name: repository.name } }); - }; - - const getStepLabel = (step: string) => { - switch (step) { - case "unlock": - return "Unlock Repository"; - case "check": - return "Check Repository"; - case "repair_index": - return "Repair Index"; - case "recheck": - return "Re-check Repository"; - default: - return step; - } - }; - return ( - <> - -
-
-

Repository Information

-
-
-
Name
-

{repository.name}

-
-
-
Backend
-

{repository.type}

-
-
-
Compression Mode
-

{repository.compressionMode || "off"}

-
-
-
Status
-

{repository.status || "unknown"}

-
-
-
Created At
-

{new Date(repository.createdAt).toLocaleString()}

-
-
-
Last Checked
-

- {repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"} -

-
-
-
- - {repository.lastError && ( + +
+
+

Repository Information

+
-
-

Last Error

- -
-
-

{repository.lastError}

-
+
Name
+

{repository.name}

- )} - -
-

Configuration

-
-
{JSON.stringify(repository.config, null, 2)}
+
+
Backend
+

{repository.type}

+
+
+
Compression Mode
+

{repository.compressionMode || "off"}

+
+
+
Status
+

{repository.status || "unknown"}

+
+
+
Created At
+

{new Date(repository.createdAt).toLocaleString()}

+
+
+
Last Checked
+

+ {repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"} +

- - - - - - Doctor Results - Repository doctor operation completed - - - {doctorMutation.data && ( -
- {doctorMutation.data.steps.map((step) => ( -
-
- {getStepLabel(step.step)} - - {step.success ? "Success" : "Warning"} - -
- {step.error &&

{step.error}

} -
- ))} + {repository.lastError && ( +
+
+

Last Error

- )} -
- +
+

{repository.lastError}

+
- - - + )} +
+

Configuration

+
+
{JSON.stringify(repository.config, null, 2)}
+
+
+
+ ); }; From 5f35cfd4c2159a593039a16a4f4a3becbc31c299 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 9 Nov 2025 11:33:14 +0100 Subject: [PATCH 4/7] feat: throttle logs during backup --- apps/server/package.json | 1 + apps/server/src/utils/restic.ts | 98 ++++++++++++--------------------- apps/server/src/utils/spawn.ts | 56 +++++++++++++++++++ bun.lock | 5 +- 4 files changed, 97 insertions(+), 63 deletions(-) create mode 100644 apps/server/src/utils/spawn.ts diff --git a/apps/server/package.json b/apps/server/package.json index fba9f71..bd347e9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,6 +15,7 @@ "dockerode": "^4.0.8", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.6", + "es-toolkit": "^1.41.0", "hono": "^4.9.2", "hono-openapi": "^1.1.0", "http-errors-enhanced": "^3.0.2", diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 49e577e..c289d00 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { spawn } from "node:child_process"; +import { throttle } from "es-toolkit"; import type { RepositoryConfig } from "@ironmount/schemas/restic"; import { type } from "arktype"; import { $ } from "bun"; @@ -9,6 +9,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants"; import { logger } from "./logger"; import { cryptoUtils } from "./crypto"; import type { RetentionPolicy } from "../modules/backups/backups.dto"; +import { safeSpawn } from "./spawn"; const backupOutputSchema = type({ message_type: "'summary'", @@ -149,67 +150,41 @@ const backup = async ( args.push("--json"); - return new Promise((resolve, reject) => { - const child = spawn("restic", args, { - env: { ...process.env, ...env }, - signal: options?.signal, - }); + const logData = throttle((data: string) => { + logger.info(data.trim()); + }, 5000); - let stdout = ""; + let stdout = ""; - child.stdout.on("data", (data) => { - stdout = data.toString(); - logger.info(data.toString()); - }); - - child.stderr.on("data", (data) => { - logger.error(data.toString()); - }); - - child.on("error", async (error) => { - if (includeFile) { - await fs.unlink(includeFile).catch(() => {}); - } - - if (error.name === "AbortError") { - logger.info("Restic backup process was aborted"); - reject(error); - } else { - logger.error(`Restic backup process error: ${error.message}`); - reject(new Error(`Restic backup process error: ${error.message}`)); - } - }); - - child.on("close", async (code) => { - if (includeFile) { - await fs.unlink(includeFile).catch(() => {}); - } - - if (code !== 0) { - logger.error(`Restic backup failed with exit code ${code}`); - reject(new Error(`Restic backup failed`)); - return; - } - - try { - const lastLine = stdout.trim(); - const resSummary = JSON.parse(lastLine ?? "{}"); - - const result = backupOutputSchema(resSummary); - - if (result instanceof type.errors) { - logger.error(`Restic backup output validation failed: ${result}`); - reject(new Error(`Restic backup output validation failed: ${result}`)); - return; - } - - resolve(result); - } catch (error) { - logger.error(`Failed to parse restic backup output: ${error}`); - reject(new Error(`Failed to parse restic backup output: ${error}`)); - } - }); + await safeSpawn({ + command: "restic", + args, + env, + signal: options?.signal, + onStdout: (data) => { + stdout = data; + logData(data); + }, + onStderr: (error) => { + logger.error(error.trim()); + }, + finally: async () => { + includeFile && (await fs.unlink(includeFile).catch(() => {})); + }, }); + + const lastLine = stdout.trim(); + const resSummary = JSON.parse(lastLine ?? "{}"); + + const result = backupOutputSchema(resSummary); + + if (result instanceof type.errors) { + logger.error(`Restic backup output validation failed: ${result}`); + + throw new Error(`Restic backup output validation failed: ${result}`); + } + + return result; }; const restoreOutputSchema = type({ @@ -370,7 +345,6 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: args.push("--prune"); args.push("--json"); - // await $`restic unlock --repo ${repoUrl}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow(); if (res.exitCode !== 0) { @@ -465,7 +439,7 @@ const unlock = async (config: RepositoryConfig) => { const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); - const res = await $`restic unlock --repo ${repoUrl} --json`.env(env).nothrow(); + const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow(); if (res.exitCode !== 0) { logger.error(`Restic unlock failed: ${res.stderr}`); @@ -501,7 +475,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean }) }; } - const hasErrors = stdout.includes("error") || stdout.includes("Fatal"); + const hasErrors = stdout.includes("Fatal"); logger.info(`Restic check completed for repository: ${repoUrl}`); return { diff --git a/apps/server/src/utils/spawn.ts b/apps/server/src/utils/spawn.ts new file mode 100644 index 0000000..023ec95 --- /dev/null +++ b/apps/server/src/utils/spawn.ts @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; + +interface Params { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + onStdout?: (data: string) => void; + onStderr?: (error: string) => void; + onError?: (error: Error) => Promise | void; + onClose?: (code: number | null) => Promise | void; + finally?: () => Promise | void; +} + +export const safeSpawn = (params: Params) => { + const { command, args, env = {}, signal, ...callbacks } = params; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: { ...process.env, ...env }, + signal: signal, + }); + + child.stdout.on("data", (data) => { + if (callbacks.onStdout) { + callbacks.onStdout(data.toString()); + } + }); + + child.stderr.on("data", (data) => { + if (callbacks.onStderr) { + callbacks.onStderr(data.toString()); + } + }); + + child.on("error", async (error) => { + if (callbacks.onError) { + await callbacks.onError(error); + } + if (callbacks.finally) { + await callbacks.finally(); + } + reject(error); + }); + + child.on("close", async (code) => { + if (callbacks.onClose) { + await callbacks.onClose(code); + } + if (callbacks.finally) { + await callbacks.finally(); + } + resolve(code); + }); + }); +}; diff --git a/bun.lock b/bun.lock index b529419..9eb481b 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,7 @@ "dockerode": "^4.0.8", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.6", + "es-toolkit": "^1.41.0", "hono": "^4.9.2", "hono-openapi": "^1.1.0", "http-errors-enhanced": "^3.0.2", @@ -812,7 +813,7 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="], + "es-toolkit": ["es-toolkit@1.41.0", "", {}, "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA=="], "esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], @@ -1594,6 +1595,8 @@ "protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="], + "recharts/es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], From 5e908dc945e34c1a7f2398f5c467680bcfc164b6 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 9 Nov 2025 12:11:00 +0100 Subject: [PATCH 5/7] feat: backup progress --- .../client/app/components/snapshots-table.tsx | 6 +- apps/client/app/components/ui/progress.tsx | 22 ++++ apps/client/app/hooks/use-server-events.ts | 26 ++++- .../components/backup-progress-card.tsx | 100 ++++++++++++++++++ .../backups/components/schedule-summary.tsx | 3 + .../modules/repositories/tabs/snapshots.tsx | 13 --- apps/client/app/utils/utils.ts | 14 +++ apps/client/package.json | 1 + apps/server/src/core/events.ts | 12 +++ .../src/modules/backups/backups.service.ts | 12 ++- .../src/modules/events/events.controller.ts | 20 ++++ apps/server/src/utils/restic.ts | 39 ++++++- bun.lock | 9 ++ 13 files changed, 256 insertions(+), 21 deletions(-) create mode 100644 apps/client/app/components/ui/progress.tsx create mode 100644 apps/client/app/modules/backups/components/backup-progress-card.tsx diff --git a/apps/client/app/components/snapshots-table.tsx b/apps/client/app/components/snapshots-table.tsx index e33a199..922ddde 100644 --- a/apps/client/app/components/snapshots-table.tsx +++ b/apps/client/app/components/snapshots-table.tsx @@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/api-client/types.gen"; import { ByteSize } from "~/components/bytes-size"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; -import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots"; +import { formatDuration } from "~/utils/utils"; type Snapshot = ListSnapshotsResponse[number]; @@ -62,9 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
- - {formatSnapshotDuration(snapshot.duration / 1000)} - + {formatDuration(snapshot.duration / 1000)}
diff --git a/apps/client/app/components/ui/progress.tsx b/apps/client/app/components/ui/progress.tsx new file mode 100644 index 0000000..90ec025 --- /dev/null +++ b/apps/client/app/components/ui/progress.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "~/lib/utils"; + +function Progress({ className, value, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/apps/client/app/hooks/use-server-events.ts b/apps/client/app/hooks/use-server-events.ts index 4b873aa..43c7205 100644 --- a/apps/client/app/hooks/use-server-events.ts +++ b/apps/client/app/hooks/use-server-events.ts @@ -5,19 +5,33 @@ type ServerEventType = | "connected" | "heartbeat" | "backup:started" + | "backup:progress" | "backup:completed" | "volume:mounted" | "volume:unmounted" | "volume:updated"; -interface BackupEvent { +export interface BackupEvent { scheduleId: number; volumeName: string; repositoryName: string; status?: "success" | "error"; } -interface VolumeEvent { +export interface BackupProgressEvent { + scheduleId: number; + volumeName: string; + repositoryName: string; + seconds_elapsed: number; + percent_done: number; + total_files: number; + files_done: number; + total_bytes: number; + bytes_done: number; + current_files: string[]; +} + +export interface VolumeEvent { volumeName: string; } @@ -51,6 +65,14 @@ export function useServerEvents() { }); }); + eventSource.addEventListener("backup:progress", (e) => { + const data = JSON.parse(e.data) as BackupProgressEvent; + + handlersRef.current.get("backup:progress")?.forEach((handler) => { + handler(data); + }); + }); + eventSource.addEventListener("backup:completed", (e) => { const data = JSON.parse(e.data) as BackupEvent; console.log("[SSE] Backup completed:", data); diff --git a/apps/client/app/modules/backups/components/backup-progress-card.tsx b/apps/client/app/modules/backups/components/backup-progress-card.tsx new file mode 100644 index 0000000..97429e5 --- /dev/null +++ b/apps/client/app/modules/backups/components/backup-progress-card.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +import { ByteSize, formatBytes } from "~/components/bytes-size"; +import { Card } from "~/components/ui/card"; +import { Progress } from "~/components/ui/progress"; +import { type BackupProgressEvent, useServerEvents } from "~/hooks/use-server-events"; +import { formatDuration } from "~/utils/utils"; + +type Props = { + scheduleId: number; +}; + +export const BackupProgressCard = ({ scheduleId }: Props) => { + const { addEventListener } = useServerEvents(); + const [progress, setProgress] = useState(null); + + useEffect(() => { + const unsubscribe = addEventListener("backup:progress", (data) => { + const progressData = data as BackupProgressEvent; + if (progressData.scheduleId === scheduleId) { + setProgress(progressData); + } + }); + + const unsubscribeComplete = addEventListener("backup:completed", (data) => { + const completedData = data as { scheduleId: number }; + if (completedData.scheduleId === scheduleId) { + setProgress(null); + } + }); + + return () => { + unsubscribe(); + unsubscribeComplete(); + }; + }, [addEventListener, scheduleId]); + + if (!progress) { + return ( +
+
+
+ Starting backup... +
+
+ ); + } + + const percentDone = Math.round(progress.percent_done * 100); + const currentFile = progress.current_files[0] || ""; + const fileName = currentFile.split("/").pop() || currentFile; + const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed); + + return ( + +
+
+
+ Backup in progress +
+ {percentDone}% +
+ + + +
+
+

Files

+

+ {progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()} +

+
+
+

Data

+

+ / +

+
+
+

Elapsed

+

{formatDuration(progress.seconds_elapsed)}

+
+
+

Speed

+

+ {progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."} +

+
+
+ + {fileName && ( +
+

Current file

+

+ {fileName} +

+
+ )} + + ); +}; diff --git a/apps/client/app/modules/backups/components/schedule-summary.tsx b/apps/client/app/modules/backups/components/schedule-summary.tsx index edb8b2b..de19964 100644 --- a/apps/client/app/modules/backups/components/schedule-summary.tsx +++ b/apps/client/app/modules/backups/components/schedule-summary.tsx @@ -13,6 +13,7 @@ import { AlertDialogTitle, } from "~/components/ui/alert-dialog"; import type { BackupSchedule } from "~/lib/types"; +import { BackupProgressCard } from "./backup-progress-card"; type Props = { schedule: BackupSchedule; @@ -144,6 +145,8 @@ export const ScheduleSummary = (props: Props) => { + {schedule.lastBackupStatus === "in_progress" && } + diff --git a/apps/client/app/modules/repositories/tabs/snapshots.tsx b/apps/client/app/modules/repositories/tabs/snapshots.tsx index 341d1b8..8bc21cf 100644 --- a/apps/client/app/modules/repositories/tabs/snapshots.tsx +++ b/apps/client/app/modules/repositories/tabs/snapshots.tsx @@ -1,5 +1,4 @@ import { useQuery } from "@tanstack/react-query"; -import { intervalToDuration } from "date-fns"; import { Database } from "lucide-react"; import { useState } from "react"; import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen"; @@ -15,18 +14,6 @@ type Props = { repository: Repository; }; -export const formatSnapshotDuration = (seconds: number) => { - const duration = intervalToDuration({ start: 0, end: seconds * 1000 }); - const parts: string[] = []; - - if (duration.days) parts.push(`${duration.days}d`); - if (duration.hours) parts.push(`${duration.hours}h`); - if (duration.minutes) parts.push(`${duration.minutes}m`); - if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`); - - return parts.join(" "); -}; - export const RepositorySnapshotsTabContent = ({ repository }: Props) => { const [searchQuery, setSearchQuery] = useState(""); diff --git a/apps/client/app/utils/utils.ts b/apps/client/app/utils/utils.ts index a3a5736..dc5d3a2 100644 --- a/apps/client/app/utils/utils.ts +++ b/apps/client/app/utils/utils.ts @@ -1,3 +1,5 @@ +import { intervalToDuration } from "date-fns"; + export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => { if (frequency === "hourly") { return "0 * * * *"; @@ -15,3 +17,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`; }; + +export const formatDuration = (seconds: number) => { + const duration = intervalToDuration({ start: 0, end: seconds * 1000 }); + const parts: string[] = []; + + if (duration.days) parts.push(`${duration.days}d`); + if (duration.hours) parts.push(`${duration.hours}h`); + if (duration.minutes) parts.push(`${duration.minutes}m`); + if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`); + + return parts.join(" "); +}; diff --git a/apps/client/package.json b/apps/client/package.json index 9abd821..d95e118 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", diff --git a/apps/server/src/core/events.ts b/apps/server/src/core/events.ts index 2e5d838..8e55c0e 100644 --- a/apps/server/src/core/events.ts +++ b/apps/server/src/core/events.ts @@ -6,6 +6,18 @@ import type { TypedEmitter } from "tiny-typed-emitter"; */ interface ServerEvents { "backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void; + "backup:progress": (data: { + scheduleId: number; + volumeName: string; + repositoryName: string; + seconds_elapsed: number; + percent_done: number; + total_files: number; + files_done: number; + total_bytes: number; + bytes_done: number; + current_files: string[]; + }) => void; "backup:completed": (data: { scheduleId: number; volumeName: string; diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts index 4d26a52..6ed67e6 100644 --- a/apps/server/src/modules/backups/backups.service.ts +++ b/apps/server/src/modules/backups/backups.service.ts @@ -224,7 +224,17 @@ const executeBackup = async (scheduleId: number, manual = false) => { backupOptions.include = schedule.includePatterns; } - await restic.backup(repository.config, volumePath, backupOptions); + await restic.backup(repository.config, volumePath, { + ...backupOptions, + onProgress: (progress) => { + serverEvents.emit("backup:progress", { + scheduleId, + volumeName: volume.name, + repositoryName: repository.name, + ...progress, + }); + }, + }); if (schedule.retentionPolicy) { await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() }); diff --git a/apps/server/src/modules/events/events.controller.ts b/apps/server/src/modules/events/events.controller.ts index 038aabe..06bdb9f 100644 --- a/apps/server/src/modules/events/events.controller.ts +++ b/apps/server/src/modules/events/events.controller.ts @@ -19,6 +19,24 @@ export const eventsController = new Hono().get("/", (c) => { }); }; + const onBackupProgress = (data: { + scheduleId: number; + volumeName: string; + repositoryName: string; + secondsElapsed: number; + percentDone: number; + totalFiles: number; + filesDone: number; + totalBytes: number; + bytesDone: number; + currentFiles: string[]; + }) => { + stream.writeSSE({ + data: JSON.stringify(data), + event: "backup:progress", + }); + }; + const onBackupCompleted = (data: { scheduleId: number; volumeName: string; @@ -53,6 +71,7 @@ export const eventsController = new Hono().get("/", (c) => { }; serverEvents.on("backup:started", onBackupStarted); + serverEvents.on("backup:progress", onBackupProgress); serverEvents.on("backup:completed", onBackupCompleted); serverEvents.on("volume:mounted", onVolumeMounted); serverEvents.on("volume:unmounted", onVolumeUnmounted); @@ -64,6 +83,7 @@ export const eventsController = new Hono().get("/", (c) => { logger.info("Client disconnected from SSE endpoint"); keepAlive = false; serverEvents.off("backup:started", onBackupStarted); + serverEvents.off("backup:progress", onBackupProgress); serverEvents.off("backup:completed", onBackupCompleted); serverEvents.off("volume:mounted", onVolumeMounted); serverEvents.off("volume:unmounted", onVolumeUnmounted); diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index c289d00..bccc325 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -113,10 +113,29 @@ const init = async (config: RepositoryConfig) => { return { success: true, error: null }; }; +const backupProgressSchema = type({ + message_type: "'status'", + seconds_elapsed: "number", + percent_done: "number", + total_files: "number", + files_done: "number", + total_bytes: "number", + bytes_done: "number", + current_files: "string[]", +}); + +export type BackupProgress = typeof backupProgressSchema.infer; + const backup = async ( config: RepositoryConfig, source: string, - options?: { exclude?: string[]; include?: string[]; tags?: string[]; signal?: AbortSignal }, + options?: { + exclude?: string[]; + include?: string[]; + tags?: string[]; + signal?: AbortSignal; + onProgress?: (progress: BackupProgress) => void; + }, ) => { const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); @@ -154,6 +173,20 @@ const backup = async ( logger.info(data.trim()); }, 5000); + const streamProgress = throttle((data: string) => { + if (options?.onProgress) { + try { + const jsonData = JSON.parse(data); + const progress = backupProgressSchema(jsonData); + if (!(progress instanceof type.errors)) { + options.onProgress(progress); + } + } catch (_) { + // Ignore JSON parse errors for non-JSON lines + } + } + }, 1000); + let stdout = ""; await safeSpawn({ @@ -164,6 +197,10 @@ const backup = async ( onStdout: (data) => { stdout = data; logData(data); + + if (options?.onProgress) { + streamProgress(data); + } }, onStderr: (error) => { logger.error(error.trim()); diff --git a/bun.lock b/bun.lock index 9eb481b..6575e51 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", @@ -377,6 +378,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], @@ -1485,6 +1488,10 @@ "@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], @@ -1663,6 +1670,8 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], From 4b981bdcacf451424a6b8f72ab49c12c5ad154a2 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 9 Nov 2025 12:25:15 +0100 Subject: [PATCH 6/7] style: use card in backup progress waiting --- .../app/modules/backups/components/backup-progress-card.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/client/app/modules/backups/components/backup-progress-card.tsx b/apps/client/app/modules/backups/components/backup-progress-card.tsx index 97429e5..95c3b5a 100644 --- a/apps/client/app/modules/backups/components/backup-progress-card.tsx +++ b/apps/client/app/modules/backups/components/backup-progress-card.tsx @@ -36,12 +36,12 @@ export const BackupProgressCard = ({ scheduleId }: Props) => { if (!progress) { return ( -
+
- Starting backup... + Backup in progress
-
+
); } From 2ec8d4c1dd5573d51c62417bcfb1b33610dbcca5 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 9 Nov 2025 12:34:12 +0100 Subject: [PATCH 7/7] chore: small fixes --- .dockerignore | 6 +----- .../app/modules/repositories/routes/snapshot-details.tsx | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.dockerignore b/.dockerignore index f1df89d..7cac851 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -** +* !turbo.json !bun.lock @@ -20,11 +20,7 @@ !packages/**/src/** # License files and attributions - !LICENSE !NOTICES.md !LICENSES/** -# Node modules - -**/node_modules/** diff --git a/apps/client/app/modules/repositories/routes/snapshot-details.tsx b/apps/client/app/modules/repositories/routes/snapshot-details.tsx index f2255ec..a86d982 100644 --- a/apps/client/app/modules/repositories/routes/snapshot-details.tsx +++ b/apps/client/app/modules/repositories/routes/snapshot-details.tsx @@ -73,7 +73,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
Short ID: -

{data.snapshot.short_id}

+

{data.snapshot.short_id}

Hostname: @@ -87,7 +87,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps Paths:
{data.snapshot.paths.map((path) => ( -

+

{path}

))}