Compare commits

..

13 Commits

Author SHA1 Message Date
Nicolas Meienberger
dd36397346 Merge branch 'steveiliop56-main' 2025-11-09 12:34:44 +01:00
Nicolas Meienberger
2ec8d4c1dd chore: small fixes 2025-11-09 12:34:12 +01:00
Nicolas Meienberger
4b981bdcac style: use card in backup progress waiting 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
5e908dc945 feat: backup progress 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
5f35cfd4c2 feat: throttle logs during backup 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
1152939373 feat: repository doctor always visible 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
94398f81bf style: use card in backup progress waiting 2025-11-09 12:25:15 +01:00
Stavros
db0d153610 chore: format 2025-11-09 13:16:14 +02:00
Nicolas Meienberger
5ff48f4d5d feat: backup progress 2025-11-09 12:11:00 +01:00
Stavros
ffca433a43 fix: accessibility and responsiveness fixes 2025-11-09 13:04:14 +02:00
Nicolas Meienberger
4389029ba5 feat: throttle logs during backup 2025-11-09 11:33:14 +01:00
Nicolas Meienberger
927db77f60 feat: repository doctor always visible 2025-11-09 11:31:44 +01:00
Nicolas Meienberger
3e80850396 fix(backup): only keep last line of stdout in memory 2025-11-08 23:41:29 +01:00
28 changed files with 551 additions and 299 deletions

View File

@@ -23,3 +23,4 @@
!LICENSE !LICENSE
!NOTICES.md !NOTICES.md
!LICENSES/** !LICENSES/**

View File

@@ -5,6 +5,7 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--breakpoint-xs: 32rem;
--font-sans: --font-sans:
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji"; "Noto Color Emoji";
@@ -12,16 +13,16 @@
html, html,
body { body {
@apply bg-white dark:bg-[#131313];
overflow-x: hidden; overflow-x: hidden;
width: 100%; width: 100%;
position: relative; position: relative;
overscroll-behavior: none; overscroll-behavior: none;
scrollbar-width: thin; scrollbar-width: thin;
}
@media (prefers-color-scheme: dark) { body {
color-scheme: dark; @apply bg-[#131313];
} min-height: 100dvh;
} }
.main-content { .main-content {
@@ -70,8 +71,6 @@ body {
} }
:root { :root {
color-scheme: dark;
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
@@ -109,6 +108,8 @@ body {
} }
.dark { .dark {
color-scheme: dark;
--background: #131313; --background: #131313;
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: #131313; --card: #131313;

View File

@@ -9,7 +9,7 @@ type AuthLayoutProps = {
export function AuthLayout({ title, description, children }: AuthLayoutProps) { export function AuthLayout({ title, description, children }: AuthLayoutProps) {
return ( return (
<div className="flex min-h-screen"> <div className="flex mt-[25%] lg:mt-0 lg:min-h-screen">
<div className="flex flex-1 items-center justify-center bg-background p-8"> <div className="flex flex-1 items-center justify-center bg-background p-8">
<div className="w-full max-w-md space-y-8"> <div className="w-full max-w-md space-y-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -26,7 +26,7 @@ export function AuthLayout({ title, description, children }: AuthLayoutProps) {
</div> </div>
</div> </div>
<div <div
className="hidden lg:block lg:flex-1 dither-xl bg-cover bg-center" className="hidden lg:block lg:flex-1 dither-lg bg-cover bg-center"
style={{ backgroundImage: "url(/images/background.jpg)" }} style={{ backgroundImage: "url(/images/background.jpg)" }}
/> />
</div> </div>

View File

@@ -76,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
</header> </header>
<div className="main-content flex-1 overflow-y-auto"> <div className="main-content flex-1 overflow-y-auto">
<GridBackground> <GridBackground>
<main className="flex flex-col p-2 pt-2 sm:p-8 sm:pt-6 mx-auto"> <main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
<Outlet /> <Outlet />
</main> </main>
</GridBackground> </GridBackground>

View File

@@ -20,7 +20,12 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
)} )}
> >
<span>{isOn ? enabledLabel : disabledLabel}</span> <span>{isOn ? enabledLabel : disabledLabel}</span>
<Switch disabled={disabled} checked={isOn} onCheckedChange={toggle} /> <Switch
disabled={disabled}
checked={isOn}
onCheckedChange={toggle}
aria-label={isOn ? `Toggle ${enabledLabel}` : `Toggle ${disabledLabel}`}
/>
</div> </div>
); );
}; };

View File

@@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { ByteSize } from "~/components/bytes-size"; import { ByteSize } from "~/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots"; import { formatDuration } from "~/utils/utils";
type Snapshot = ListSnapshotsResponse[number]; type Snapshot = ListSnapshotsResponse[number];
@@ -62,9 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableCell className="hidden md:table-cell"> <TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
{formatSnapshotDuration(snapshot.duration / 1000)}
</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="hidden lg:table-cell"> <TableCell className="hidden lg:table-cell">

View File

@@ -38,7 +38,10 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
)} )}
/> />
)} )}
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} /> <span
aria-label={status}
className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)}
/>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View File

@@ -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<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -5,19 +5,33 @@ type ServerEventType =
| "connected" | "connected"
| "heartbeat" | "heartbeat"
| "backup:started" | "backup:started"
| "backup:progress"
| "backup:completed" | "backup:completed"
| "volume:mounted" | "volume:mounted"
| "volume:unmounted" | "volume:unmounted"
| "volume:updated"; | "volume:updated";
interface BackupEvent { export interface BackupEvent {
scheduleId: number; scheduleId: number;
volumeName: string; volumeName: string;
repositoryName: string; repositoryName: string;
status?: "success" | "error"; 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; 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) => { eventSource.addEventListener("backup:completed", (e) => {
const data = JSON.parse(e.data) as BackupEvent; const data = JSON.parse(e.data) as BackupEvent;
console.log("[SSE] Backup completed:", data); console.log("[SSE] Backup completed:", data);

View File

@@ -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<BackupProgressEvent | null>(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 (
<Card className="p-4">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="font-medium">Backup in progress</span>
</div>
</Card>
);
}
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 (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="font-medium">Backup in progress</span>
</div>
<span className="text-sm font-medium text-primary">{percentDone}%</span>
</div>
<Progress value={percentDone} className="h-2" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs uppercase text-muted-foreground">Files</p>
<p className="font-medium">
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Data</p>
<p className="font-medium">
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} />
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Speed</p>
<p className="font-medium">
{progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."}
</p>
</div>
</div>
{fileName && (
<div className="pt-2 border-t border-border">
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile}>
{fileName}
</p>
</div>
)}
</Card>
);
};

View File

@@ -232,7 +232,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
withCheckboxes={true} withCheckboxes={true}
foldersOnly={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 && ( {selectedPaths.size > 0 && (
<div className="mt-4"> <div className="mt-4">

View File

@@ -13,6 +13,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import type { BackupSchedule } from "~/lib/types"; import type { BackupSchedule } from "~/lib/types";
import { BackupProgressCard } from "./backup-progress-card";
type Props = { type Props = {
schedule: BackupSchedule; schedule: BackupSchedule;
@@ -144,6 +145,8 @@ export const ScheduleSummary = (props: Props) => {
</CardContent> </CardContent>
</Card> </Card>
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>

View File

@@ -1,9 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { toast } from "sonner";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import {
deleteRepositoryMutation, deleteRepositoryMutation,
doctorRepositoryMutation,
getRepositoryOptions, getRepositoryOptions,
listSnapshotsOptions, listSnapshotsOptions,
} from "~/api-client/@tanstack/react-query.gen"; } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { RepositoryInfoTabContent } from "../tabs/info"; import { RepositoryInfoTabContent } from "../tabs/info";
import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
import { Loader2 } from "lucide-react";
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
@@ -38,10 +40,13 @@ export function meta({ params }: Route.MetaArgs) {
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const repository = await getRepository({ path: { name: params.name ?? "" } }); const repository = await getRepository({ path: { name: params.name ?? "" } });
if (repository.data) return repository.data; if (repository.data) return repository.data;
return redirect("/repositories");
}; };
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) { export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>(); const [showDoctorResults, setShowDoctorResults] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -50,17 +55,15 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
const activeTab = searchParams.get("tab") || "info"; const activeTab = searchParams.get("tab") || "info";
const { data } = useQuery({ const { data } = useQuery({
...getRepositoryOptions({ path: { name: name ?? "" } }), ...getRepositoryOptions({ path: { name: loaderData.name } }),
initialData: loaderData, initialData: loaderData,
refetchInterval: 10000, refetchInterval: 10000,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
useEffect(() => { useEffect(() => {
if (name) { queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } })); }, [queryClient, data.name]);
}
}, [name, queryClient]);
const deleteRepo = useMutation({ const deleteRepo = useMutation({
...deleteRepositoryMutation(), ...deleteRepositoryMutation(),
@@ -75,39 +78,78 @@ 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 = () => { const handleConfirmDelete = () => {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
deleteRepo.mutate({ path: { name: name ?? "" } }); deleteRepo.mutate({ path: { name: data.name } });
}; };
if (!name) { const getStepLabel = (step: string) => {
return <div>Repository not found</div>; switch (step) {
} case "unlock":
return "Unlock Repository";
if (!data) { case "check":
return <div>Loading...</div>; return "Check Repository";
} case "repair_index":
return "Repair Index";
case "recheck":
return "Re-check Repository";
default:
return step;
}
};
return ( return (
<> <>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2"> <span
<span className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
className={cn( "bg-green-500/10 text-green-500": data.status === "healthy",
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", "bg-red-500/10 text-red-500": data.status === "error",
{ })}
"bg-green-500/10 text-green-500": data.status === "healthy", >
"bg-red-500/10 text-red-500": data.status === "error", {data.status || "unknown"}
}, </span>
)} <span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
>
{data.status || "unknown"}
</span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
</div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<Button
onClick={() => doctorMutation.mutate({ path: { name: data.name } })}
disabled={doctorMutation.isPending}
variant={"outline"}
>
{doctorMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Running Doctor...
</>
) : (
"Run Doctor"
)}
</Button>
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}> <Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
Delete Delete
</Button> </Button>
@@ -132,8 +174,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete repository?</AlertDialogTitle> <AlertDialogTitle>Delete repository?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete the repository <strong>{name}</strong>? This action cannot be undone and Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
will remove all backup data. and will remove all backup data.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
@@ -147,6 +189,46 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
</div> </div>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
</AlertDialogHeader>
{doctorMutation.data && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{doctorMutation.data.steps.map((step) => (
<div
key={step.step}
className={cn("border rounded-md p-3", {
"bg-green-500/10 border-green-500/20": step.success,
"bg-yellow-500/10 border-yellow-500/20": !step.success,
})}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
<span
className={cn("text-xs px-2 py-1 rounded", {
"bg-green-500/20 text-green-500": step.success,
"bg-yellow-500/20 text-yellow-500": !step.success,
})}
>
{step.success ? "Success" : "Warning"}
</span>
</div>
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
</div>
))}
</div>
)}
<div className="flex justify-end">
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
</div>
</AlertDialogContent>
</AlertDialog>
</> </>
); );
} }

View File

@@ -18,14 +18,19 @@ export function meta({ params }: Route.MetaArgs) {
} }
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } }); const snapshot = await getSnapshotDetails({
path: { name: params.name, snapshotId: params.snapshotId },
});
if (snapshot.data) return snapshot.data; if (snapshot.data) return snapshot.data;
return redirect("/repositories"); return redirect("/repositories");
}; };
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) { export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>(); const { name, snapshotId } = useParams<{
name: string;
snapshotId: string;
}>();
const { data } = useQuery({ const { data } = useQuery({
...listSnapshotFilesOptions({ ...listSnapshotFilesOptions({
@@ -64,11 +69,11 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<span className="text-muted-foreground">Snapshot ID:</span> <span className="text-muted-foreground">Snapshot ID:</span>
<p className="font-mono">{data.snapshot.id}</p> <p className="font-mono break-all">{data.snapshot.id}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Short ID:</span> <span className="text-muted-foreground">Short ID:</span>
<p className="font-mono">{data.snapshot.short_id}</p> <p className="font-mono break-all">{data.snapshot.short_id}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Hostname:</span> <span className="text-muted-foreground">Hostname:</span>
@@ -82,7 +87,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<span className="text-muted-foreground">Paths:</span> <span className="text-muted-foreground">Paths:</span>
<div className="space-y-1 mt-1"> <div className="space-y-1 mt-1">
{data.snapshot.paths.map((path) => ( {data.snapshot.paths.map((path) => (
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded"> <p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
{path} {path}
</p> </p>
))} ))}

View File

@@ -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 { 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 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 = { type Props = {
repository: Repository; repository: Repository;
}; };
export const RepositoryInfoTabContent = ({ repository }: Props) => { 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 ( return (
<> <Card className="p-6">
<Card className="p-6"> <div className="space-y-6">
<div className="space-y-6"> <div>
<div> <h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
{repository.lastError && (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="text-sm font-medium text-muted-foreground">Name</div>
<h3 className="text-lg font-semibold text-red-500">Last Error</h3> <p className="mt-1 text-sm">{repository.name}</p>
<Button onClick={handleDoctor} disabled={doctorMutation.isPending} variant={"outline"} size="sm">
{doctorMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Running Doctor...
</>
) : (
"Run Doctor"
)}
</Button>
</div>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p>
</div>
</div> </div>
)} <div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<div> <p className="mt-1 text-sm">{repository.type}</p>
<h3 className="text-lg font-semibold mb-4">Configuration</h3> </div>
<div className="bg-muted/50 rounded-md p-4"> <div>
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre> <div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div> </div>
</div> </div>
</div> </div>
</Card> {repository.lastError && (
<div>
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}> <div className="flex items-center justify-between mb-4">
<AlertDialogContent className="max-w-2xl"> <h3 className="text-lg font-semibold text-red-500">Last Error</h3>
<AlertDialogHeader>
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
</AlertDialogHeader>
{doctorMutation.data && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{doctorMutation.data.steps.map((step) => (
<div
key={step.step}
className={cn("border rounded-md p-3", {
"bg-green-500/10 border-green-500/20": step.success,
"bg-yellow-500/10 border-yellow-500/20": !step.success,
})}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
<span
className={cn("text-xs px-2 py-1 rounded", {
"bg-green-500/20 text-green-500": step.success,
"bg-yellow-500/20 text-yellow-500": !step.success,
})}
>
{step.success ? "Success" : "Warning"}
</span>
</div>
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
</div>
))}
</div> </div>
)}
<div className="flex justify-end"> <div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<Button onClick={() => setShowDoctorResults(false)}>Close</Button> <p className="text-sm text-red-500">{repository.lastError}</p>
</div>
</div> </div>
</AlertDialogContent> )}
</AlertDialog> <div>
</> <h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div>
</div>
</div>
</Card>
); );
}; };

View File

@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { intervalToDuration } from "date-fns";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen"; import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
@@ -15,18 +14,6 @@ type Props = {
repository: Repository; 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) => { export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");

View File

@@ -118,14 +118,12 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
return ( return (
<> <>
<div className="flex items-center justify-between"> <div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
<div> <div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="flex items-center gap-2"> <StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)} </span>
</span> <VolumeIcon size={14} backend={volume?.config.backend} />
<VolumeIcon size={14} backend={volume?.config.backend} />
</div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button

View File

@@ -38,7 +38,7 @@ const queryClient = new QueryClient({
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" style={{ colorScheme: "dark" }} className="dark"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
@@ -52,7 +52,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links /> <Links />
</head> </head>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<body> <body className="dark">
{children} {children}
<Toaster /> <Toaster />
<ScrollRestoration /> <ScrollRestoration />

View File

@@ -1,3 +1,5 @@
import { intervalToDuration } from "date-fns";
export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => { export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => {
if (frequency === "hourly") { if (frequency === "hourly") {
return "0 * * * *"; return "0 * * * *";
@@ -15,3 +17,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`; 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(" ");
};

View File

@@ -15,6 +15,7 @@
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@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-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",

View File

@@ -29,5 +29,6 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
}, },
}, },
allowedHosts: true,
}, },
}); });

View File

@@ -15,6 +15,7 @@
"dockerode": "^4.0.8", "dockerode": "^4.0.8",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"drizzle-orm": "^0.44.6", "drizzle-orm": "^0.44.6",
"es-toolkit": "^1.41.0",
"hono": "^4.9.2", "hono": "^4.9.2",
"hono-openapi": "^1.1.0", "hono-openapi": "^1.1.0",
"http-errors-enhanced": "^3.0.2", "http-errors-enhanced": "^3.0.2",

View File

@@ -6,6 +6,18 @@ import type { TypedEmitter } from "tiny-typed-emitter";
*/ */
interface ServerEvents { interface ServerEvents {
"backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void; "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: { "backup:completed": (data: {
scheduleId: number; scheduleId: number;
volumeName: string; volumeName: string;

View File

@@ -197,7 +197,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await db await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now() }) .set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null })
.where(eq(backupSchedulesTable.id, scheduleId)); .where(eq(backupSchedulesTable.id, scheduleId));
const abortController = new AbortController(); const abortController = new AbortController();
@@ -224,7 +224,17 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.include = schedule.includePatterns; 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) { if (schedule.retentionPolicy) {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() }); await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });

View File

@@ -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: { const onBackupCompleted = (data: {
scheduleId: number; scheduleId: number;
volumeName: string; volumeName: string;
@@ -53,6 +71,7 @@ export const eventsController = new Hono().get("/", (c) => {
}; };
serverEvents.on("backup:started", onBackupStarted); serverEvents.on("backup:started", onBackupStarted);
serverEvents.on("backup:progress", onBackupProgress);
serverEvents.on("backup:completed", onBackupCompleted); serverEvents.on("backup:completed", onBackupCompleted);
serverEvents.on("volume:mounted", onVolumeMounted); serverEvents.on("volume:mounted", onVolumeMounted);
serverEvents.on("volume:unmounted", onVolumeUnmounted); serverEvents.on("volume:unmounted", onVolumeUnmounted);
@@ -64,6 +83,7 @@ export const eventsController = new Hono().get("/", (c) => {
logger.info("Client disconnected from SSE endpoint"); logger.info("Client disconnected from SSE endpoint");
keepAlive = false; keepAlive = false;
serverEvents.off("backup:started", onBackupStarted); serverEvents.off("backup:started", onBackupStarted);
serverEvents.off("backup:progress", onBackupProgress);
serverEvents.off("backup:completed", onBackupCompleted); serverEvents.off("backup:completed", onBackupCompleted);
serverEvents.off("volume:mounted", onVolumeMounted); serverEvents.off("volume:mounted", onVolumeMounted);
serverEvents.off("volume:unmounted", onVolumeUnmounted); serverEvents.off("volume:unmounted", onVolumeUnmounted);

View File

@@ -1,7 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; 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 { RepositoryConfig } from "@ironmount/schemas/restic";
import { type } from "arktype"; import { type } from "arktype";
import { $ } from "bun"; import { $ } from "bun";
@@ -9,6 +9,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants";
import { logger } from "./logger"; import { logger } from "./logger";
import { cryptoUtils } from "./crypto"; import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto"; import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn";
const backupOutputSchema = type({ const backupOutputSchema = type({
message_type: "'summary'", message_type: "'summary'",
@@ -112,10 +113,29 @@ const init = async (config: RepositoryConfig) => {
return { success: true, error: null }; 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 ( const backup = async (
config: RepositoryConfig, config: RepositoryConfig,
source: string, 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 repoUrl = buildRepoUrl(config);
const env = await buildEnv(config); const env = await buildEnv(config);
@@ -149,68 +169,59 @@ const backup = async (
args.push("--json"); args.push("--json");
return new Promise((resolve, reject) => { const logData = throttle((data: string) => {
const child = spawn("restic", args, { logger.info(data.trim());
env: { ...process.env, ...env }, }, 5000);
signal: options?.signal,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += 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}: ${stderr}`);
reject(new Error(`Restic backup failed: ${stderr}`));
return;
}
const streamProgress = throttle((data: string) => {
if (options?.onProgress) {
try { try {
const outputLines = stdout.trim().split("\n"); const jsonData = JSON.parse(data);
const lastLine = outputLines[outputLines.length - 1]; const progress = backupProgressSchema(jsonData);
const resSummary = JSON.parse(lastLine ?? "{}"); if (!(progress instanceof type.errors)) {
options.onProgress(progress);
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;
} }
} catch (_) {
resolve(result); // Ignore JSON parse errors for non-JSON lines
} catch (error) {
logger.error(`Failed to parse restic backup output: ${error}`);
reject(new Error(`Failed to parse restic backup output: ${error}`));
} }
}); }
}, 1000);
let stdout = "";
await safeSpawn({
command: "restic",
args,
env,
signal: options?.signal,
onStdout: (data) => {
stdout = data;
logData(data);
if (options?.onProgress) {
streamProgress(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({ const restoreOutputSchema = type({
@@ -371,7 +382,6 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
args.push("--prune"); args.push("--prune");
args.push("--json"); args.push("--json");
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
@@ -466,7 +476,7 @@ const unlock = async (config: RepositoryConfig) => {
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(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) { if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`); logger.error(`Restic unlock failed: ${res.stderr}`);
@@ -502,7 +512,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}`); logger.info(`Restic check completed for repository: ${repoUrl}`);
return { return {

View File

@@ -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> | void;
onClose?: (code: number | null) => Promise<void> | void;
finally?: () => Promise<void> | 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);
});
});
};

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@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-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
@@ -76,6 +77,7 @@
"dockerode": "^4.0.8", "dockerode": "^4.0.8",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"drizzle-orm": "^0.44.6", "drizzle-orm": "^0.44.6",
"es-toolkit": "^1.41.0",
"hono": "^4.9.2", "hono": "^4.9.2",
"hono-openapi": "^1.1.0", "hono-openapi": "^1.1.0",
"http-errors-enhanced": "^3.0.2", "http-errors-enhanced": "^3.0.2",
@@ -376,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-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-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=="], "@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=="],
@@ -812,7 +816,7 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "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=="], "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=="],
@@ -1484,6 +1488,10 @@
"@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "@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=="], "@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=="], "@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=="],
@@ -1594,6 +1602,8 @@
"protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="], "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=="], "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=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -1660,6 +1670,8 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "@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-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=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],