mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
18 Commits
v0.6.0-alp
...
v0.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf33b15b3e | ||
|
|
2b0fea9645 | ||
|
|
e9eeda304b | ||
|
|
4ddc45a74f | ||
|
|
2aa90ec44d | ||
|
|
dd36397346 | ||
|
|
2ec8d4c1dd | ||
|
|
4b981bdcac | ||
|
|
5e908dc945 | ||
|
|
5f35cfd4c2 | ||
|
|
1152939373 | ||
|
|
94398f81bf | ||
|
|
db0d153610 | ||
|
|
5ff48f4d5d | ||
|
|
ffca433a43 | ||
|
|
4389029ba5 | ||
|
|
927db77f60 | ||
|
|
3e80850396 |
@@ -23,3 +23,4 @@
|
|||||||
!LICENSE
|
!LICENSE
|
||||||
!NOTICES.md
|
!NOTICES.md
|
||||||
!LICENSES/**
|
!LICENSES/**
|
||||||
|
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
privileged: true
|
||||||
@@ -67,7 +67,7 @@ If you want to track a local directory on the same server where Ironmount is run
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -124,24 +124,21 @@ Ironmount allows you to easily restore your data from backups. To restore data,
|
|||||||
|
|
||||||
Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
|
Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
|
||||||
|
|
||||||
In order to enable this feature, you need to run Ironmount with privileged mode and mount /proc from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
In order to enable this feature, you need to change your bind mount `/var/lib/ironmount` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
- cap_add:
|
|
||||||
- - SYS_ADMIN
|
|
||||||
+ privileged: true
|
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/ironmount:/var/lib/ironmount
|
- - /var/lib/ironmount:/var/lib/ironmount
|
||||||
+ - /proc:/host/proc
|
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart the Ironmount container to apply the changes:
|
Restart the Ironmount container to apply the changes:
|
||||||
@@ -155,24 +152,23 @@ docker compose up -d
|
|||||||
|
|
||||||
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
|
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
|
||||||
|
|
||||||
In order to enable this feature, you need to run Ironmount with privileged mode and mount several items from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
In order to enable this feature, you need to run Ironmount with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
- cap_add:
|
cap_add:
|
||||||
- - SYS_ADMIN
|
- SYS_ADMIN
|
||||||
+ privileged: true
|
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/ironmount:/var/lib/ironmount
|
- - /var/lib/ironmount:/var/lib/ironmount
|
||||||
+ - /proc:/host/proc
|
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||||
+ - /run/docker/plugins:/run/docker/plugins
|
+ - /run/docker/plugins:/run/docker/plugins
|
||||||
+ - /var/run/docker.sock:/var/run/docker.sock
|
+ - /var/run/docker.sock:/var/run/docker.sock
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
22
apps/client/app/components/ui/progress.tsx
Normal file
22
apps/client/app/components/ui/progress.tsx
Normal 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 };
|
||||||
18
apps/client/app/components/ui/textarea.tsx
Normal file
18
apps/client/app/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,13 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/com
|
|||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { VolumeFileBrowser } from "~/components/volume-file-browser";
|
import { VolumeFileBrowser } from "~/components/volume-file-browser";
|
||||||
import type { BackupSchedule, Volume } from "~/lib/types";
|
import type { BackupSchedule, Volume } from "~/lib/types";
|
||||||
import { deepClean } from "~/utils/object";
|
import { deepClean } from "~/utils/object";
|
||||||
|
|
||||||
const formSchema = type({
|
const internalFormSchema = type({
|
||||||
repositoryId: "string",
|
repositoryId: "string",
|
||||||
excludePatterns: "string[]?",
|
excludePatternsText: "string?",
|
||||||
includePatterns: "string[]?",
|
includePatterns: "string[]?",
|
||||||
frequency: "string",
|
frequency: "string",
|
||||||
dailyTime: "string?",
|
dailyTime: "string?",
|
||||||
@@ -27,7 +28,7 @@ const formSchema = type({
|
|||||||
keepMonthly: "number?",
|
keepMonthly: "number?",
|
||||||
keepYearly: "number?",
|
keepYearly: "number?",
|
||||||
});
|
});
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
||||||
|
|
||||||
export const weeklyDays = [
|
export const weeklyDays = [
|
||||||
{ label: "Monday", value: "1" },
|
{ label: "Monday", value: "1" },
|
||||||
@@ -39,7 +40,11 @@ export const weeklyDays = [
|
|||||||
{ label: "Sunday", value: "0" },
|
{ label: "Sunday", value: "0" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type BackupScheduleFormValues = typeof formSchema.infer;
|
type InternalFormValues = typeof internalFormSchema.infer;
|
||||||
|
|
||||||
|
export type BackupScheduleFormValues = Omit<InternalFormValues, "excludePatternsText"> & {
|
||||||
|
excludePatterns?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
@@ -50,7 +55,7 @@ type Props = {
|
|||||||
formId: string;
|
formId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => {
|
const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -72,16 +77,36 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo
|
|||||||
dailyTime,
|
dailyTime,
|
||||||
weeklyDay,
|
weeklyDay,
|
||||||
includePatterns: schedule.includePatterns || undefined,
|
includePatterns: schedule.includePatterns || undefined,
|
||||||
|
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||||
...schedule.retentionPolicy,
|
...schedule.retentionPolicy,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
||||||
const form = useForm<BackupScheduleFormValues>({
|
const form = useForm<InternalFormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
resolver: arktypeResolver(cleanSchema as unknown as typeof internalFormSchema),
|
||||||
defaultValues: backupScheduleToFormValues(initialValues),
|
defaultValues: backupScheduleToFormValues(initialValues),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(data: InternalFormValues) => {
|
||||||
|
// Convert excludePatternsText string to excludePatterns array
|
||||||
|
const { excludePatternsText, ...rest } = data;
|
||||||
|
const excludePatterns = excludePatternsText
|
||||||
|
? excludePatternsText
|
||||||
|
.split("\n")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
...rest,
|
||||||
|
excludePatterns,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onSubmit],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: repositoriesData } = useQuery({
|
const { data: repositoriesData } = useQuery({
|
||||||
...listRepositoriesOptions(),
|
...listRepositoriesOptions(),
|
||||||
});
|
});
|
||||||
@@ -102,7 +127,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]"
|
className="grid gap-4 xl:grid-cols-[minmax(0,_2.3fr)_minmax(320px,_1fr)]"
|
||||||
id={formId}
|
id={formId}
|
||||||
>
|
>
|
||||||
@@ -232,7 +257,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="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
||||||
/>
|
/>
|
||||||
{selectedPaths.size > 0 && (
|
{selectedPaths.size > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -249,6 +274,47 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Exclude patterns</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Optionally specify patterns to exclude from backups. Enter one pattern per line (e.g., *.tmp,
|
||||||
|
node_modules/**, .cache/).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="excludePatternsText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Exclusion patterns</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="*.tmp node_modules/** .cache/ *.log"
|
||||||
|
className="font-mono text-sm min-h-[120px]"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Patterns support glob syntax. See
|
||||||
|
<a
|
||||||
|
href="https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
Restic documentation
|
||||||
|
</a>
|
||||||
|
for more details.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Retention policy</CardTitle>
|
<CardTitle>Retention policy</CardTitle>
|
||||||
@@ -408,6 +474,33 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{formValues.includePatterns.map((path) => (
|
||||||
|
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||||
|
{path}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{formValues.excludePatternsText && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{formValues.excludePatternsText
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((pattern) => (
|
||||||
|
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
|
||||||
|
{pattern.trim()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Retention</p>
|
<p className="text-xs uppercase text-muted-foreground">Retention</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
||||||
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
|
|
||||||
{
|
|
||||||
"bg-green-500/10 text-green-500": data.status === "healthy",
|
"bg-green-500/10 text-green-500": data.status === "healthy",
|
||||||
"bg-red-500/10 text-red-500": data.status === "error",
|
"bg-red-500/10 text-red-500": data.status === "error",
|
||||||
},
|
})}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{data.status || "unknown"}
|
{data.status || "unknown"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,72 +1,12 @@
|
|||||||
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>
|
||||||
@@ -100,28 +40,17 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{repository.lastError && (
|
{repository.lastError && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||||
<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>
|
||||||
|
|
||||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
<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>
|
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||||
<div className="bg-muted/50 rounded-md p-4">
|
<div className="bg-muted/50 rounded-md p-4">
|
||||||
@@ -130,46 +59,5 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|
||||||
|
|||||||
@@ -118,15 +118,13 @@ 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
|
||||||
onClick={() => mountVol.mutate({ path: { name } })}
|
onClick={() => mountVol.mutate({ path: { name } })}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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(" ");
|
||||||
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ await Bun.build({
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
minify: {
|
minify: {
|
||||||
whitespace: true,
|
whitespace: true,
|
||||||
identifiers: true,
|
identifiers: false,
|
||||||
syntax: true,
|
syntax: true,
|
||||||
keepNames: true,
|
|
||||||
},
|
},
|
||||||
external: ["ssh2"],
|
external: ["ssh2"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { logger } from "../utils/logger";
|
|||||||
|
|
||||||
export type SystemCapabilities = {
|
export type SystemCapabilities = {
|
||||||
docker: boolean;
|
docker: boolean;
|
||||||
hostProc: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
|
let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
|
||||||
@@ -29,7 +28,6 @@ export async function getCapabilities(): Promise<SystemCapabilities> {
|
|||||||
async function detectCapabilities(): Promise<SystemCapabilities> {
|
async function detectCapabilities(): Promise<SystemCapabilities> {
|
||||||
return {
|
return {
|
||||||
docker: await detectDocker(),
|
docker: await detectDocker(),
|
||||||
hostProc: await detectHostProc(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,23 +53,3 @@ async function detectDocker(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if host proc is available by attempting to access /host/proc/1/ns/mnt
|
|
||||||
* This allows using nsenter to execute mount commands in the host namespace
|
|
||||||
*/
|
|
||||||
async function detectHostProc(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await fs.access("/host/proc/1/ns/mnt");
|
|
||||||
|
|
||||||
logger.info("Host proc capability: enabled");
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
logger.warn(
|
|
||||||
"Host proc capability: disabled. " +
|
|
||||||
"To enable: mount /proc:/host/proc:ro in docker-compose.yml. " +
|
|
||||||
"Mounts will be executed in container namespace instead of host namespace.",
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
|
|||||||
export const REPOSITORY_BASE = "/var/lib/ironmount/repositories";
|
export const REPOSITORY_BASE = "/var/lib/ironmount/repositories";
|
||||||
export const DATABASE_URL = "/var/lib/ironmount/data/ironmount.db";
|
export const DATABASE_URL = "/var/lib/ironmount/data/ironmount.db";
|
||||||
export const RESTIC_PASS_FILE = "/var/lib/ironmount/data/restic.pass";
|
export const RESTIC_PASS_FILE = "/var/lib/ironmount/data/restic.pass";
|
||||||
|
export const SOCKET_PATH = "/run/docker/plugins/ironmount.sock";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { backupScheduleController } from "./modules/backups/backups.controller";
|
|||||||
import { eventsController } from "./modules/events/events.controller";
|
import { eventsController } from "./modules/events/events.controller";
|
||||||
import { handleServiceError } from "./utils/errors";
|
import { handleServiceError } from "./utils/errors";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
|
import { shutdown } from "./modules/lifecycle/shutdown";
|
||||||
|
import { SOCKET_PATH } from "./core/constants";
|
||||||
|
|
||||||
export const generalDescriptor = (app: Hono) =>
|
export const generalDescriptor = (app: Hono) =>
|
||||||
openAPIRouteHandler(app, {
|
openAPIRouteHandler(app, {
|
||||||
@@ -70,17 +72,15 @@ runDbMigrations();
|
|||||||
const { docker } = await getCapabilities();
|
const { docker } = await getCapabilities();
|
||||||
|
|
||||||
if (docker) {
|
if (docker) {
|
||||||
const socketPath = "/run/docker/plugins/ironmount.sock";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir("/run/docker/plugins", { recursive: true });
|
await fs.mkdir("/run/docker/plugins", { recursive: true });
|
||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
unix: socketPath,
|
unix: SOCKET_PATH,
|
||||||
fetch: driver.fetch,
|
fetch: driver.fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Docker volume plugin server running at ${socketPath}`);
|
logger.info(`Docker volume plugin server running at ${SOCKET_PATH}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to start Docker volume plugin server: ${error}`);
|
logger.error(`Failed to start Docker volume plugin server: ${error}`);
|
||||||
}
|
}
|
||||||
@@ -96,3 +96,16 @@ startup();
|
|||||||
logger.info(`Server is running at http://localhost:4096`);
|
logger.info(`Server is running at http://localhost:4096`);
|
||||||
|
|
||||||
export type AppType = typeof app;
|
export type AppType = typeof app;
|
||||||
|
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
logger.info("SIGTERM received, starting graceful shutdown...");
|
||||||
|
|
||||||
|
await shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
logger.info("SIGINT received, starting graceful shutdown...");
|
||||||
|
await shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,31 +1,14 @@
|
|||||||
import { execFile as execFileCb } from "node:child_process";
|
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as npath from "node:path";
|
import * as npath from "node:path";
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { getCapabilities } from "../../../core/capabilities";
|
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
|
import { $ } from "bun";
|
||||||
const execFile = promisify(execFileCb);
|
|
||||||
|
|
||||||
export const executeMount = async (args: string[]): Promise<void> => {
|
export const executeMount = async (args: string[]): Promise<void> => {
|
||||||
const capabilities = await getCapabilities();
|
|
||||||
let stderr: string | undefined;
|
let stderr: string | undefined;
|
||||||
|
|
||||||
if (capabilities.hostProc) {
|
const result = await $`mount ${args}`.nothrow();
|
||||||
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], {
|
stderr = result.stderr.toString();
|
||||||
timeout: OPERATION_TIMEOUT,
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
});
|
|
||||||
stderr = result.stderr;
|
|
||||||
} else {
|
|
||||||
const result = await execFile("mount", args, {
|
|
||||||
timeout: OPERATION_TIMEOUT,
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
});
|
|
||||||
stderr = result.stderr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logger.warn(stderr.trim());
|
logger.warn(stderr.trim());
|
||||||
@@ -33,22 +16,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const executeUnmount = async (path: string): Promise<void> => {
|
export const executeUnmount = async (path: string): Promise<void> => {
|
||||||
const capabilities = await getCapabilities();
|
|
||||||
let stderr: string | undefined;
|
let stderr: string | undefined;
|
||||||
|
|
||||||
if (capabilities.hostProc) {
|
const result = await $`umount -l -f ${path}`.nothrow();
|
||||||
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], {
|
stderr = result.stderr.toString();
|
||||||
timeout: OPERATION_TIMEOUT,
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
});
|
|
||||||
stderr = result.stderr;
|
|
||||||
} else {
|
|
||||||
const result = await execFile("umount", ["-l", "-f", path], {
|
|
||||||
timeout: OPERATION_TIMEOUT,
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
});
|
|
||||||
stderr = result.stderr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stderr?.trim()) {
|
if (stderr?.trim()) {
|
||||||
logger.warn(stderr.trim());
|
logger.warn(stderr.trim());
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
28
apps/server/src/modules/lifecycle/shutdown.ts
Normal file
28
apps/server/src/modules/lifecycle/shutdown.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Scheduler } from "../../core/scheduler";
|
||||||
|
import { eq, or } from "drizzle-orm";
|
||||||
|
import { db } from "../../db/db";
|
||||||
|
import { volumesTable } from "../../db/schema";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import { SOCKET_PATH } from "../../core/constants";
|
||||||
|
import { createVolumeBackend } from "../backends/backend";
|
||||||
|
|
||||||
|
export const shutdown = async () => {
|
||||||
|
await Scheduler.stop();
|
||||||
|
|
||||||
|
await Bun.file(SOCKET_PATH)
|
||||||
|
.delete()
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore errors if the socket file does not exist
|
||||||
|
});
|
||||||
|
|
||||||
|
const volumes = await db.query.volumesTable.findMany({
|
||||||
|
where: or(eq(volumesTable.status, "mounted")),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const volume of volumes) {
|
||||||
|
const backend = createVolumeBackend(volume);
|
||||||
|
const { status, error } = await backend.unmount();
|
||||||
|
|
||||||
|
logger.info(`Volume ${volume.name} unmount status: ${status}${error ? `, error: ${error}` : ""}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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'",
|
||||||
@@ -81,7 +82,7 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
|||||||
|
|
||||||
const buildEnv = async (config: RepositoryConfig) => {
|
const buildEnv = async (config: RepositoryConfig) => {
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
RESTIC_CACHE_DIR: "/tmp/restic-cache",
|
RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache",
|
||||||
RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE,
|
RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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,64 @@ 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,
|
|
||||||
});
|
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 = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
|
||||||
|
|
||||||
child.stdout.on("data", (data) => {
|
const res = await safeSpawn({
|
||||||
stdout += data.toString();
|
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(() => {}));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stderr.on("data", (data) => {
|
if (res.exitCode !== 0) {
|
||||||
stderr += data.toString();
|
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||||
});
|
throw new Error(`Restic backup failed: ${res.stderr}`);
|
||||||
|
|
||||||
child.on("error", async (error) => {
|
|
||||||
if (includeFile) {
|
|
||||||
await fs.unlink(includeFile).catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.name === "AbortError") {
|
const lastLine = stdout.trim();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const outputLines = stdout.trim().split("\n");
|
|
||||||
const lastLine = outputLines[outputLines.length - 1];
|
|
||||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||||
|
|
||||||
const result = backupOutputSchema(resSummary);
|
const result = backupOutputSchema(resSummary);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
logger.error(`Restic backup output validation failed: ${result}`);
|
logger.error(`Restic backup output validation failed: ${result}`);
|
||||||
reject(new Error(`Restic backup output validation failed: ${result}`));
|
|
||||||
return;
|
throw new Error(`Restic backup output validation failed: ${result}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(result);
|
return result;
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to parse restic backup output: ${error}`);
|
|
||||||
reject(new Error(`Failed to parse restic backup output: ${error}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreOutputSchema = type({
|
const restoreOutputSchema = type({
|
||||||
@@ -371,7 +387,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 +481,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 +517,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 {
|
||||||
|
|||||||
79
apps/server/src/utils/spawn.ts
Normal file
79
apps/server/src/utils/spawn.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpawnResult = {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const safeSpawn = (params: Params) => {
|
||||||
|
const { command, args, env = {}, signal, ...callbacks } = params;
|
||||||
|
|
||||||
|
return new Promise<SpawnResult>((resolve) => {
|
||||||
|
let stdoutData = "";
|
||||||
|
let stderrData = "";
|
||||||
|
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
signal: signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on("data", (data) => {
|
||||||
|
if (callbacks.onStdout) {
|
||||||
|
callbacks.onStdout(data.toString());
|
||||||
|
} else {
|
||||||
|
stdoutData += data.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (data) => {
|
||||||
|
if (callbacks.onStderr) {
|
||||||
|
callbacks.onStderr(data.toString());
|
||||||
|
} else {
|
||||||
|
stderrData += data.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", async (error) => {
|
||||||
|
if (callbacks.onError) {
|
||||||
|
await callbacks.onError(error);
|
||||||
|
}
|
||||||
|
if (callbacks.finally) {
|
||||||
|
await callbacks.finally();
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
exitCode: -1,
|
||||||
|
stdout: stdoutData,
|
||||||
|
stderr: stderrData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", async (code) => {
|
||||||
|
if (callbacks.onClose) {
|
||||||
|
await callbacks.onClose(code);
|
||||||
|
}
|
||||||
|
if (callbacks.finally) {
|
||||||
|
await callbacks.finally();
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
exitCode: code === null ? -1 : code,
|
||||||
|
stdout: stdoutData,
|
||||||
|
stderr: stderrData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
14
bun.lock
14
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -34,4 +34,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/ironmount/:/var/lib/ironmount/
|
- /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||||
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|||||||
Reference in New Issue
Block a user