mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
20 Commits
v0.6.0-alp
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b0fea9645 | ||
|
|
e9eeda304b | ||
|
|
4ddc45a74f | ||
|
|
2aa90ec44d | ||
|
|
dd36397346 | ||
|
|
2ec8d4c1dd | ||
|
|
4b981bdcac | ||
|
|
5e908dc945 | ||
|
|
5f35cfd4c2 | ||
|
|
1152939373 | ||
|
|
94398f81bf | ||
|
|
db0d153610 | ||
|
|
5ff48f4d5d | ||
|
|
ffca433a43 | ||
|
|
4389029ba5 | ||
|
|
927db77f60 | ||
|
|
3e80850396 | ||
|
|
5f620b4c45 | ||
|
|
3abf8ab12d | ||
|
|
b5ba03da3d |
@@ -23,3 +23,4 @@
|
||||
!LICENSE
|
||||
!NOTICES.md
|
||||
!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
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
@@ -67,7 +67,7 @@ If you want to track a local directory on the same server where Ironmount is run
|
||||
```diff
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
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.
|
||||
|
||||
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
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
- cap_add:
|
||||
- - SYS_ADMIN
|
||||
+ privileged: true
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /proc:/host/proc
|
||||
- - /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
services:
|
||||
ironmount:
|
||||
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
||||
image: ghcr.io/nicotsx/ironmount:v0.6
|
||||
container_name: ironmount
|
||||
restart: unless-stopped
|
||||
- cap_add:
|
||||
- - SYS_ADMIN
|
||||
+ privileged: true
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
ports:
|
||||
- "4096:4096"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
+ - /proc:/host/proc
|
||||
- - /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
|
||||
```
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
updateBackupSchedule,
|
||||
getBackupScheduleForVolume,
|
||||
runBackupNow,
|
||||
stopBackup,
|
||||
getSystemInfo,
|
||||
downloadResticPassword,
|
||||
} from "../sdk.gen";
|
||||
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
||||
import type {
|
||||
@@ -93,7 +95,11 @@ import type {
|
||||
GetBackupScheduleForVolumeData,
|
||||
RunBackupNowData,
|
||||
RunBackupNowResponse,
|
||||
StopBackupData,
|
||||
StopBackupResponse,
|
||||
GetSystemInfoData,
|
||||
DownloadResticPasswordData,
|
||||
DownloadResticPasswordResponse,
|
||||
} from "../types.gen";
|
||||
import { client as _heyApiClient } from "../client.gen";
|
||||
|
||||
@@ -1104,6 +1110,45 @@ export const runBackupNowMutation = (
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const stopBackupQueryKey = (options: Options<StopBackupData>) => createQueryKey("stopBackup", options);
|
||||
|
||||
/**
|
||||
* Stop a backup that is currently in progress
|
||||
*/
|
||||
export const stopBackupOptions = (options: Options<StopBackupData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await stopBackup({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: stopBackupQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop a backup that is currently in progress
|
||||
*/
|
||||
export const stopBackupMutation = (
|
||||
options?: Partial<Options<StopBackupData>>,
|
||||
): UseMutationOptions<StopBackupResponse, DefaultError, Options<StopBackupData>> => {
|
||||
const mutationOptions: UseMutationOptions<StopBackupResponse, DefaultError, Options<StopBackupData>> = {
|
||||
mutationFn: async (localOptions) => {
|
||||
const { data } = await stopBackup({
|
||||
...options,
|
||||
...localOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey("getSystemInfo", options);
|
||||
|
||||
/**
|
||||
@@ -1123,3 +1168,47 @@ export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => {
|
||||
queryKey: getSystemInfoQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
export const downloadResticPasswordQueryKey = (options?: Options<DownloadResticPasswordData>) =>
|
||||
createQueryKey("downloadResticPassword", options);
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
*/
|
||||
export const downloadResticPasswordOptions = (options?: Options<DownloadResticPasswordData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await downloadResticPassword({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: downloadResticPasswordQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
*/
|
||||
export const downloadResticPasswordMutation = (
|
||||
options?: Partial<Options<DownloadResticPasswordData>>,
|
||||
): UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> => {
|
||||
const mutationOptions: UseMutationOptions<
|
||||
DownloadResticPasswordResponse,
|
||||
DefaultError,
|
||||
Options<DownloadResticPasswordData>
|
||||
> = {
|
||||
mutationFn: async (localOptions) => {
|
||||
const { data } = await downloadResticPassword({
|
||||
...options,
|
||||
...localOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
@@ -74,8 +74,13 @@ import type {
|
||||
GetBackupScheduleForVolumeResponses,
|
||||
RunBackupNowData,
|
||||
RunBackupNowResponses,
|
||||
StopBackupData,
|
||||
StopBackupResponses,
|
||||
StopBackupErrors,
|
||||
GetSystemInfoData,
|
||||
GetSystemInfoResponses,
|
||||
DownloadResticPasswordData,
|
||||
DownloadResticPasswordResponses,
|
||||
} from "./types.gen";
|
||||
import { client as _heyApiClient } from "./client.gen";
|
||||
|
||||
@@ -530,6 +535,16 @@ export const runBackupNow = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop a backup that is currently in progress
|
||||
*/
|
||||
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}/stop",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get system information including available capabilities
|
||||
*/
|
||||
@@ -541,3 +556,19 @@ export const getSystemInfo = <ThrowOnError extends boolean = false>(
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||
*/
|
||||
export const downloadResticPassword = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<DownloadResticPasswordData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/system/restic-password",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export type RegisterResponses = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
@@ -44,6 +45,7 @@ export type LoginResponses = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
@@ -85,6 +87,7 @@ export type GetMeResponses = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
@@ -149,6 +152,8 @@ export type ListVolumesResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -175,6 +180,7 @@ export type ListVolumesResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -196,6 +202,8 @@ export type CreateVolumeData = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -222,6 +230,7 @@ export type CreateVolumeData = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -241,6 +250,8 @@ export type CreateVolumeResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -267,6 +278,7 @@ export type CreateVolumeResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -288,6 +300,8 @@ export type TestConnectionData = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -314,6 +328,7 @@ export type TestConnectionData = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -386,6 +401,8 @@ export type GetVolumeResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -412,6 +429,7 @@ export type GetVolumeResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -435,6 +453,8 @@ export type UpdateVolumeData = {
|
||||
config?:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -461,6 +481,7 @@ export type UpdateVolumeData = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -488,6 +509,8 @@ export type UpdateVolumeResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -514,6 +537,7 @@ export type UpdateVolumeResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -962,12 +986,11 @@ export type DoctorRepositoryResponses = {
|
||||
* Doctor operation completed
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
steps: Array<{
|
||||
error: string | null;
|
||||
output: string | null;
|
||||
step: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
output?: string;
|
||||
}>;
|
||||
success: boolean;
|
||||
};
|
||||
@@ -1036,6 +1059,8 @@ export type ListBackupSchedulesResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -1062,6 +1087,7 @@ export type ListBackupSchedulesResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -1219,6 +1245,8 @@ export type GetBackupScheduleResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -1245,6 +1273,7 @@ export type GetBackupScheduleResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -1383,6 +1412,8 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -1409,6 +1440,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -1448,6 +1480,33 @@ export type RunBackupNowResponses = {
|
||||
|
||||
export type RunBackupNowResponse = RunBackupNowResponses[keyof RunBackupNowResponses];
|
||||
|
||||
export type StopBackupData = {
|
||||
body?: never;
|
||||
path: {
|
||||
scheduleId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/api/v1/backups/{scheduleId}/stop";
|
||||
};
|
||||
|
||||
export type StopBackupErrors = {
|
||||
/**
|
||||
* No backup is currently running for this schedule
|
||||
*/
|
||||
409: unknown;
|
||||
};
|
||||
|
||||
export type StopBackupResponses = {
|
||||
/**
|
||||
* Backup stopped successfully
|
||||
*/
|
||||
200: {
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type StopBackupResponse = StopBackupResponses[keyof StopBackupResponses];
|
||||
|
||||
export type GetSystemInfoData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
@@ -1468,6 +1527,24 @@ export type GetSystemInfoResponses = {
|
||||
|
||||
export type GetSystemInfoResponse = GetSystemInfoResponses[keyof GetSystemInfoResponses];
|
||||
|
||||
export type DownloadResticPasswordData = {
|
||||
body?: {
|
||||
password: string;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: "/api/v1/system/restic-password";
|
||||
};
|
||||
|
||||
export type DownloadResticPasswordResponses = {
|
||||
/**
|
||||
* Restic password file content
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--breakpoint-xs: 32rem;
|
||||
--font-sans:
|
||||
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
@@ -12,16 +13,16 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-white dark:bg-[#131313];
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overscroll-behavior: none;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
@apply bg-[#131313];
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -107,6 +108,8 @@ body {
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: #131313;
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: #131313;
|
||||
|
||||
@@ -9,7 +9,7 @@ type AuthLayoutProps = {
|
||||
|
||||
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
||||
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="w-full max-w-md space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -26,7 +26,7 @@ export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
||||
</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)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { LifeBuoy } from "lucide-react";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
import { Outlet, redirect, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { appContext } from "~/context";
|
||||
@@ -16,6 +16,11 @@ export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||
const ctx = context.get(appContext);
|
||||
|
||||
if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
|
||||
throw redirect("/download-recovery-key");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -71,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||
</header>
|
||||
<div className="main-content flex-1 overflow-y-auto">
|
||||
<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 />
|
||||
</main>
|
||||
</GridBackground>
|
||||
|
||||
@@ -20,7 +20,12 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/api-client/types.gen";
|
||||
import { ByteSize } from "~/components/bytes-size";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
|
||||
type Snapshot = ListSnapshotsResponse[number];
|
||||
|
||||
@@ -62,9 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatSnapshotDuration(snapshot.duration / 1000)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ const alertVariants = cva(
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
warning: "border-orange-500/20 bg-orange-500/10 text-orange-500 [&>svg]:text-orange-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
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"
|
||||
| "heartbeat"
|
||||
| "backup:started"
|
||||
| "backup:progress"
|
||||
| "backup:completed"
|
||||
| "volume:mounted"
|
||||
| "volume:unmounted"
|
||||
| "volume:updated";
|
||||
|
||||
interface BackupEvent {
|
||||
export interface BackupEvent {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
status?: "success" | "error";
|
||||
}
|
||||
|
||||
interface VolumeEvent {
|
||||
export interface BackupProgressEvent {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
seconds_elapsed: number;
|
||||
percent_done: number;
|
||||
total_files: number;
|
||||
files_done: number;
|
||||
total_bytes: number;
|
||||
bytes_done: number;
|
||||
current_files: string[];
|
||||
}
|
||||
|
||||
export interface VolumeEvent {
|
||||
volumeName: string;
|
||||
}
|
||||
|
||||
@@ -51,6 +65,14 @@ export function useServerEvents() {
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("backup:progress", (e) => {
|
||||
const data = JSON.parse(e.data) as BackupProgressEvent;
|
||||
|
||||
handlersRef.current.get("backup:progress")?.forEach((handler) => {
|
||||
handler(data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.addEventListener("backup:completed", (e) => {
|
||||
const data = JSON.parse(e.data) as BackupEvent;
|
||||
console.log("[SSE] Backup completed:", data);
|
||||
|
||||
106
apps/client/app/modules/auth/routes/download-recovery-key.tsx
Normal file
106
apps/client/app/modules/auth/routes/download-recovery-key.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { downloadResticPasswordMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { AuthLayout } from "~/components/auth-layout";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/download-recovery-key";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Download Recovery Key" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Download your backup recovery key to ensure you can restore your data.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function DownloadRecoveryKeyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const downloadResticPassword = useMutation({
|
||||
...downloadResticPasswordMutation(),
|
||||
onSuccess: (data) => {
|
||||
const blob = new Blob([data], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "restic.pass";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Recovery key downloaded successfully!");
|
||||
navigate("/volumes", { replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to download recovery key", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
downloadResticPassword.mutate({
|
||||
body: {
|
||||
password,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Download Your Recovery Key"
|
||||
description="This is a critical step to ensure you can recover your backups"
|
||||
>
|
||||
<Alert variant="warning" className="mb-6">
|
||||
<AlertTriangle className="size-5" />
|
||||
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your Restic password is essential for recovering your backup data. If you lose access to this server without
|
||||
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Confirm Your Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
disabled={downloadResticPassword.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||
<Download size={16} className="mr-2" />
|
||||
Download Recovery Key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -44,8 +44,12 @@ export default function LoginPage() {
|
||||
|
||||
const login = useMutation({
|
||||
...loginMutation(),
|
||||
onSuccess: async () => {
|
||||
navigate("/volumes");
|
||||
onSuccess: async (data) => {
|
||||
if (data.user && !data.user.hasDownloadedResticPassword) {
|
||||
navigate("/download-recovery-key");
|
||||
} else {
|
||||
navigate("/volumes");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function OnboardingPage() {
|
||||
...registerMutation(),
|
||||
onSuccess: async () => {
|
||||
toast.success("Admin user created successfully!");
|
||||
navigate("/volumes");
|
||||
navigate("/download-recovery-key");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
|
||||
@@ -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 { Input } from "~/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { VolumeFileBrowser } from "~/components/volume-file-browser";
|
||||
import type { BackupSchedule, Volume } from "~/lib/types";
|
||||
import { deepClean } from "~/utils/object";
|
||||
|
||||
const formSchema = type({
|
||||
const internalFormSchema = type({
|
||||
repositoryId: "string",
|
||||
excludePatterns: "string[]?",
|
||||
excludePatternsText: "string?",
|
||||
includePatterns: "string[]?",
|
||||
frequency: "string",
|
||||
dailyTime: "string?",
|
||||
@@ -27,7 +28,7 @@ const formSchema = type({
|
||||
keepMonthly: "number?",
|
||||
keepYearly: "number?",
|
||||
});
|
||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||
const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
||||
|
||||
export const weeklyDays = [
|
||||
{ label: "Monday", value: "1" },
|
||||
@@ -39,7 +40,11 @@ export const weeklyDays = [
|
||||
{ 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 = {
|
||||
volume: Volume;
|
||||
@@ -50,7 +55,7 @@ type Props = {
|
||||
formId: string;
|
||||
};
|
||||
|
||||
const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFormValues | undefined => {
|
||||
const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
|
||||
if (!schedule) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -72,16 +77,36 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): BackupScheduleFo
|
||||
dailyTime,
|
||||
weeklyDay,
|
||||
includePatterns: schedule.includePatterns || undefined,
|
||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
};
|
||||
|
||||
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
||||
const form = useForm<BackupScheduleFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
||||
const form = useForm<InternalFormValues>({
|
||||
resolver: arktypeResolver(cleanSchema as unknown as typeof internalFormSchema),
|
||||
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({
|
||||
...listRepositoriesOptions(),
|
||||
});
|
||||
@@ -102,7 +127,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
return (
|
||||
<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)]"
|
||||
id={formId}
|
||||
>
|
||||
@@ -232,7 +257,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
onSelectionChange={handleSelectionChange}
|
||||
withCheckboxes={true}
|
||||
foldersOnly={true}
|
||||
className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
|
||||
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
|
||||
/>
|
||||
{selectedPaths.size > 0 && (
|
||||
<div className="mt-4">
|
||||
@@ -249,6 +274,47 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle>Retention policy</CardTitle>
|
||||
@@ -408,6 +474,33 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
|
||||
</p>
|
||||
</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>
|
||||
<p className="text-xs uppercase text-muted-foreground">Retention</p>
|
||||
<p className="font-medium">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pencil, Play, Trash2 } from "lucide-react";
|
||||
import { Pencil, Play, Square, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OnOff } from "~/components/onoff";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -13,17 +13,20 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import type { BackupSchedule } from "~/lib/types";
|
||||
import { BackupProgressCard } from "./backup-progress-card";
|
||||
|
||||
type Props = {
|
||||
schedule: BackupSchedule;
|
||||
handleToggleEnabled: (enabled: boolean) => void;
|
||||
handleRunBackupNow: () => void;
|
||||
handleStopBackup: () => void;
|
||||
handleDeleteSchedule: () => void;
|
||||
setIsEditMode: (isEdit: boolean) => void;
|
||||
};
|
||||
|
||||
export const ScheduleSummary = (props: Props) => {
|
||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleDeleteSchedule, setIsEditMode } = props;
|
||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||
props;
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
@@ -75,16 +78,17 @@ export const ScheduleSummary = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleRunBackupNow}
|
||||
disabled={schedule.lastBackupStatus === "in_progress"}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Backup now</span>
|
||||
</Button>
|
||||
{schedule.lastBackupStatus === "in_progress" ? (
|
||||
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Stop backup</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Backup now</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Edit schedule</span>
|
||||
@@ -141,6 +145,8 @@ export const ScheduleSummary = (props: Props) => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
deleteBackupScheduleMutation,
|
||||
listSnapshotsOptions,
|
||||
updateBackupScheduleMutation,
|
||||
stopBackupMutation,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/lib/errors";
|
||||
import { getCronExpression } from "~/utils/utils";
|
||||
@@ -44,9 +45,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||
|
||||
const { data: schedule } = useQuery({
|
||||
...getBackupScheduleOptions({
|
||||
path: { scheduleId: params.id },
|
||||
}),
|
||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
@@ -57,13 +56,10 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
isLoading,
|
||||
failureReason,
|
||||
} = useQuery({
|
||||
...listSnapshotsOptions({
|
||||
path: { name: schedule.repository.name },
|
||||
query: { backupId: schedule.id.toString() },
|
||||
}),
|
||||
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
|
||||
});
|
||||
|
||||
const upsertSchedule = useMutation({
|
||||
const updateSchedule = useMutation({
|
||||
...updateBackupScheduleMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup schedule saved successfully");
|
||||
@@ -82,9 +78,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
toast.success("Backup started successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to start backup", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
toast.error("Failed to start backup", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
const stopBackup = useMutation({
|
||||
...stopBackupMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup stopped successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to stop backup", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,9 +99,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
navigate("/backups");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete backup schedule", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -114,7 +116,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
|
||||
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
||||
|
||||
upsertSchedule.mutate({
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
repositoryId: formValues.repositoryId,
|
||||
@@ -128,9 +130,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (enabled: boolean) => {
|
||||
if (!schedule) return;
|
||||
|
||||
upsertSchedule.mutate({
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
repositoryId: schedule.repositoryId,
|
||||
@@ -143,28 +143,12 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
});
|
||||
};
|
||||
|
||||
const handleRunBackupNow = () => {
|
||||
if (!schedule) return;
|
||||
|
||||
runBackupNow.mutate({
|
||||
path: {
|
||||
scheduleId: schedule.id.toString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = () => {
|
||||
if (!schedule) return;
|
||||
|
||||
deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } });
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div>
|
||||
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
|
||||
<div className="flex justify-end mt-4 gap-2">
|
||||
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={upsertSchedule.isPending}>
|
||||
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}>
|
||||
Update schedule
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
||||
@@ -181,8 +165,9 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
<div className="flex flex-col gap-6">
|
||||
<ScheduleSummary
|
||||
handleToggleEnabled={handleToggleEnabled}
|
||||
handleRunBackupNow={handleRunBackupNow}
|
||||
handleDeleteSchedule={handleDeleteSchedule}
|
||||
handleRunBackupNow={() => runBackupNow.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
handleStopBackup={() => stopBackup.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
handleDeleteSchedule={() => deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
setIsEditMode={setIsEditMode}
|
||||
schedule={schedule}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { redirect, useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
deleteRepositoryMutation,
|
||||
doctorRepositoryMutation,
|
||||
getRepositoryOptions,
|
||||
listSnapshotsOptions,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
@@ -24,6 +25,7 @@ import { cn } from "~/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { RepositoryInfoTabContent } from "../tabs/info";
|
||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
@@ -38,10 +40,13 @@ export function meta({ params }: Route.MetaArgs) {
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const repository = await getRepository({ path: { name: params.name ?? "" } });
|
||||
if (repository.data) return repository.data;
|
||||
|
||||
return redirect("/repositories");
|
||||
};
|
||||
|
||||
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -50,17 +55,15 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
||||
const activeTab = searchParams.get("tab") || "info";
|
||||
|
||||
const { data } = useQuery({
|
||||
...getRepositoryOptions({ path: { name: name ?? "" } }),
|
||||
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } }));
|
||||
}
|
||||
}, [name, queryClient]);
|
||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
|
||||
}, [queryClient, data.name]);
|
||||
|
||||
const deleteRepo = useMutation({
|
||||
...deleteRepositoryMutation(),
|
||||
@@ -75,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 = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteRepo.mutate({ path: { name: name ?? "" } });
|
||||
deleteRepo.mutate({ path: { name: data.name } });
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return <div>Repository not found</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"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-red-500/10 text-red-500": data.status === "error",
|
||||
},
|
||||
)}
|
||||
>
|
||||
{data.status || "unknown"}
|
||||
</span>
|
||||
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||
<span
|
||||
className={cn("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-red-500/10 text-red-500": data.status === "error",
|
||||
})}
|
||||
>
|
||||
{data.status || "unknown"}
|
||||
</span>
|
||||
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
|
||||
</div>
|
||||
<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}>
|
||||
Delete
|
||||
</Button>
|
||||
@@ -132,8 +174,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the repository <strong>{name}</strong>? This action cannot be undone and
|
||||
will remove all backup data.
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||
and will remove all backup data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
@@ -147,6 +189,46 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</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) => {
|
||||
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;
|
||||
|
||||
return redirect("/repositories");
|
||||
};
|
||||
|
||||
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({
|
||||
...listSnapshotFilesOptions({
|
||||
@@ -64,11 +69,11 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Snapshot ID:</span>
|
||||
<p className="font-mono">{data.snapshot.id}</p>
|
||||
<p className="font-mono break-all">{data.snapshot.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<div className="space-y-1 mt-1">
|
||||
{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}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -1,177 +1,63 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Repository } from "~/lib/types";
|
||||
import { parseError } from "~/lib/errors";
|
||||
import { doctorRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
};
|
||||
|
||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
||||
|
||||
const doctorMutation = useMutation({
|
||||
...doctorRepositoryMutation(),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setShowDoctorResults(true);
|
||||
|
||||
if (data.success) {
|
||||
toast.success("Repository doctor completed successfully");
|
||||
} else {
|
||||
toast.warning("Doctor completed with some issues", {
|
||||
description: "Check the details for more information",
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to run doctor", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDoctor = () => {
|
||||
doctorMutation.mutate({ path: { name: repository.name } });
|
||||
};
|
||||
|
||||
const getStepLabel = (step: string) => {
|
||||
switch (step) {
|
||||
case "unlock":
|
||||
return "Unlock Repository";
|
||||
case "check":
|
||||
return "Check Repository";
|
||||
case "repair_index":
|
||||
return "Repair Index";
|
||||
case "recheck":
|
||||
return "Re-check Repository";
|
||||
default:
|
||||
return step;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
||||
<p className="mt-1 text-sm">{repository.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||
<p className="mt-1 text-sm">{repository.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
|
||||
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Created At</div>
|
||||
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||
<p className="mt-1 text-sm">
|
||||
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repository.lastError && (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<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 className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
||||
<p className="mt-1 text-sm">{repository.name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||
<div className="bg-muted/50 rounded-md p-4">
|
||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||
<p className="mt-1 text-sm">{repository.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
|
||||
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Created At</div>
|
||||
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||
<p className="mt-1 text-sm">
|
||||
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{doctorMutation.data?.message || "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>
|
||||
))}
|
||||
{repository.lastError && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||
<div className="bg-muted/50 rounded-md p-4">
|
||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { intervalToDuration } from "date-fns";
|
||||
import { Database } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||
@@ -15,18 +14,6 @@ type Props = {
|
||||
repository: Repository;
|
||||
};
|
||||
|
||||
export const formatSnapshotDuration = (seconds: number) => {
|
||||
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
||||
const parts: string[] = [];
|
||||
|
||||
if (duration.days) parts.push(`${duration.days}d`);
|
||||
if (duration.hours) parts.push(`${duration.hours}h`);
|
||||
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
||||
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { KeyRound, User } from "lucide-react";
|
||||
import { Download, KeyRound, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { changePasswordMutation, logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
changePasswordMutation,
|
||||
downloadResticPasswordMutation,
|
||||
logoutMutation,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { appContext } from "~/context";
|
||||
@@ -30,6 +43,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||
const [downloadPassword, setDownloadPassword] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = useMutation({
|
||||
@@ -56,6 +71,28 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const downloadResticPassword = useMutation({
|
||||
...downloadResticPasswordMutation(),
|
||||
onSuccess: (data) => {
|
||||
const blob = new Blob([data], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "restic.pass";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Restic password file downloaded successfully");
|
||||
setDownloadDialogOpen(false);
|
||||
setDownloadPassword("");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to download Restic password", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -77,6 +114,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadResticPassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!downloadPassword) {
|
||||
toast.error("Password is required");
|
||||
return;
|
||||
}
|
||||
|
||||
downloadResticPassword.mutate({
|
||||
body: {
|
||||
password: downloadPassword,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-0 gap-0">
|
||||
<div className="border-b border-border/50 bg-card-header p-6">
|
||||
@@ -143,6 +195,69 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-t border-border/50 bg-card-header p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="size-5" />
|
||||
Backup Recovery Key
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">Download your Restic password file for disaster recovery</CardDescription>
|
||||
</div>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
This file contains the encryption password used by Restic to secure your backups. Store it in a safe place
|
||||
(like a password manager or encrypted storage). If you lose access to this server, you'll need this file to
|
||||
recover your backup data.
|
||||
</p>
|
||||
|
||||
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
Download Restic Password
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleDownloadResticPassword}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Download Restic Password</DialogTitle>
|
||||
<DialogDescription>
|
||||
For security reasons, please enter your account password to download the Restic password file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-password">Your Password</Label>
|
||||
<Input
|
||||
id="download-password"
|
||||
type="password"
|
||||
value={downloadPassword}
|
||||
onChange={(e) => setDownloadPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDownloadDialogOpen(false);
|
||||
setDownloadPassword("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={downloadResticPassword.isPending}>
|
||||
Download
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,14 +118,12 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
|
||||
@@ -38,7 +38,7 @@ const queryClient = new QueryClient({
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" style={{ colorScheme: "dark" }} className="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<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 />
|
||||
</head>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<body>
|
||||
<body className="dark">
|
||||
{children}
|
||||
<Toaster />
|
||||
<ScrollRestoration />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { layout, type RouteConfig, route } from "@react-router/dev/routes";
|
||||
export default [
|
||||
route("onboarding", "./modules/auth/routes/onboarding.tsx"),
|
||||
route("login", "./modules/auth/routes/login.tsx"),
|
||||
route("download-recovery-key", "./modules/auth/routes/download-recovery-key.tsx"),
|
||||
layout("./components/layout.tsx", [
|
||||
route("/", "./routes/root.tsx"),
|
||||
route("volumes", "./modules/volumes/routes/volumes.tsx"),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { intervalToDuration } from "date-fns";
|
||||
|
||||
export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => {
|
||||
if (frequency === "hourly") {
|
||||
return "0 * * * *";
|
||||
@@ -15,3 +17,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD
|
||||
|
||||
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`;
|
||||
};
|
||||
|
||||
export const formatDuration = (seconds: number) => {
|
||||
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
||||
const parts: string[] = [];
|
||||
|
||||
if (duration.days) parts.push(`${duration.days}d`);
|
||||
if (duration.hours) parts.push(`${duration.hours}h`);
|
||||
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
||||
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
|
||||
@@ -29,5 +29,6 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
allowedHosts: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,9 +6,8 @@ await Bun.build({
|
||||
sourcemap: true,
|
||||
minify: {
|
||||
whitespace: true,
|
||||
identifiers: true,
|
||||
identifiers: false,
|
||||
syntax: true,
|
||||
keepNames: true,
|
||||
},
|
||||
external: ["ssh2"],
|
||||
});
|
||||
|
||||
1
apps/server/drizzle/0010_perfect_proemial_gods.sql
Normal file
1
apps/server/drizzle/0010_perfect_proemial_gods.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users_table` ADD `has_downloaded_restic_password` integer DEFAULT false NOT NULL;
|
||||
459
apps/server/drizzle/meta/0010_snapshot.json
Normal file
459
apps/server/drizzle/meta/0010_snapshot.json
Normal file
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "17f234ba-4123-4951-a39f-6002d537435f",
|
||||
"prevId": "6a326ac0-cb3a-4c63-8800-bc86d18e0c1d",
|
||||
"tables": {
|
||||
"backup_schedules_table": {
|
||||
"name": "backup_schedules_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"volume_id": {
|
||||
"name": "volume_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repository_id": {
|
||||
"name": "repository_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"cron_expression": {
|
||||
"name": "cron_expression",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"retention_policy": {
|
||||
"name": "retention_policy",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"name": "exclude_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"include_patterns": {
|
||||
"name": "include_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"last_backup_at": {
|
||||
"name": "last_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_status": {
|
||||
"name": "last_backup_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_error": {
|
||||
"name": "last_backup_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"next_backup_at": {
|
||||
"name": "next_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "volumes_table",
|
||||
"columnsFrom": [
|
||||
"volume_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "repositories_table",
|
||||
"columnsFrom": [
|
||||
"repository_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repositories_table": {
|
||||
"name": "repositories_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"compression_mode": {
|
||||
"name": "compression_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"last_checked": {
|
||||
"name": "last_checked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"repositories_table_name_unique": {
|
||||
"name": "repositories_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions_table": {
|
||||
"name": "sessions_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_table_user_id_users_table_id_fk": {
|
||||
"name": "sessions_table_user_id_users_table_id_fk",
|
||||
"tableFrom": "sessions_table",
|
||||
"tableTo": "users_table",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users_table": {
|
||||
"name": "users_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"has_downloaded_restic_password": {
|
||||
"name": "has_downloaded_restic_password",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_table_username_unique": {
|
||||
"name": "users_table_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"volumes_table": {
|
||||
"name": "volumes_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unmounted'"
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_health_check": {
|
||||
"name": "last_health_check",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auto_remount": {
|
||||
"name": "auto_remount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"volumes_table_name_unique": {
|
||||
"name": "volumes_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"dockerode": "^4.0.8",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"es-toolkit": "^1.41.0",
|
||||
"hono": "^4.9.2",
|
||||
"hono-openapi": "^1.1.0",
|
||||
"http-errors-enhanced": "^3.0.2",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { logger } from "../utils/logger";
|
||||
|
||||
export type SystemCapabilities = {
|
||||
docker: boolean;
|
||||
hostProc: boolean;
|
||||
};
|
||||
|
||||
let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
|
||||
@@ -29,7 +28,6 @@ export async function getCapabilities(): Promise<SystemCapabilities> {
|
||||
async function detectCapabilities(): Promise<SystemCapabilities> {
|
||||
return {
|
||||
docker: await detectDocker(),
|
||||
hostProc: await detectHostProc(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,23 +53,3 @@ async function detectDocker(): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,23 @@ import type { TypedEmitter } from "tiny-typed-emitter";
|
||||
*/
|
||||
interface ServerEvents {
|
||||
"backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void;
|
||||
"backup:progress": (data: {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
seconds_elapsed: number;
|
||||
percent_done: number;
|
||||
total_files: number;
|
||||
files_done: number;
|
||||
total_bytes: number;
|
||||
bytes_done: number;
|
||||
current_files: string[];
|
||||
}) => void;
|
||||
"backup:completed": (data: {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
status: "success" | "error";
|
||||
status: "success" | "error" | "stopped";
|
||||
}) => void;
|
||||
"volume:mounted": (data: { volumeName: string }) => void;
|
||||
"volume:unmounted": (data: { volumeName: string }) => void;
|
||||
|
||||
@@ -32,6 +32,7 @@ export const usersTable = sqliteTable("users_table", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
username: text().notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
@@ -43,7 +43,15 @@ export const authController = new Hono()
|
||||
});
|
||||
|
||||
return c.json<RegisterDto>(
|
||||
{ success: true, message: "User registered successfully", user: { id: user.id, username: user.username } },
|
||||
{
|
||||
success: true,
|
||||
message: "User registered successfully",
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
},
|
||||
201,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -64,7 +72,11 @@ export const authController = new Hono()
|
||||
return c.json<LoginDto>({
|
||||
success: true,
|
||||
message: "Login successful",
|
||||
user: { id: user.id, username: user.username },
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
||||
|
||||
@@ -18,6 +18,7 @@ const loginResponseSchema = type({
|
||||
user: type({
|
||||
id: "number",
|
||||
username: "string",
|
||||
hasDownloadedResticPassword: "boolean",
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ declare module "hono" {
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,15 @@ export class AuthService {
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { user: { id: user.id, username: user.username, createdAt: user.createdAt }, sessionId };
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
createdAt: user.createdAt,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +78,11 @@ export class AuthService {
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
user: { id: user.id, username: user.username },
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -109,6 +121,7 @@ export class AuthService {
|
||||
user: {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
hasDownloadedResticPassword: session.user.hasDownloadedResticPassword,
|
||||
},
|
||||
session: {
|
||||
id: session.session.id,
|
||||
|
||||
@@ -1,31 +1,14 @@
|
||||
import { execFile as execFileCb } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
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 { logger } from "../../../utils/logger";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
import { $ } from "bun";
|
||||
|
||||
export const executeMount = async (args: string[]): Promise<void> => {
|
||||
const capabilities = await getCapabilities();
|
||||
let stderr: string | undefined;
|
||||
|
||||
if (capabilities.hostProc) {
|
||||
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], {
|
||||
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;
|
||||
}
|
||||
const result = await $`mount ${args}`.nothrow();
|
||||
stderr = result.stderr.toString();
|
||||
|
||||
if (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> => {
|
||||
const capabilities = await getCapabilities();
|
||||
let stderr: string | undefined;
|
||||
|
||||
if (capabilities.hostProc) {
|
||||
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], {
|
||||
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;
|
||||
}
|
||||
const result = await $`umount -l -f ${path}`.nothrow();
|
||||
stderr = result.stderr.toString();
|
||||
|
||||
if (stderr?.trim()) {
|
||||
logger.warn(stderr.trim());
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getBackupScheduleForVolumeDto,
|
||||
listBackupSchedulesDto,
|
||||
runBackupNowDto,
|
||||
stopBackupDto,
|
||||
updateBackupScheduleDto,
|
||||
updateBackupScheduleBody,
|
||||
type CreateBackupScheduleDto,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
type GetBackupScheduleForVolumeResponseDto,
|
||||
type ListBackupSchedulesResponseDto,
|
||||
type RunBackupNowDto,
|
||||
type StopBackupDto,
|
||||
type UpdateBackupScheduleDto,
|
||||
} from "./backups.dto";
|
||||
import { backupsService } from "./backups.service";
|
||||
@@ -69,4 +71,11 @@ export const backupScheduleController = new Hono()
|
||||
});
|
||||
|
||||
return c.json<RunBackupNowDto>({ success: true }, 200);
|
||||
})
|
||||
.post("/:scheduleId/stop", stopBackupDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
await backupsService.stopBackup(Number(scheduleId));
|
||||
|
||||
return c.json<StopBackupDto>({ success: true }, 200);
|
||||
});
|
||||
|
||||
@@ -223,3 +223,31 @@ export const runBackupNowDto = describeRoute({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Stop a running backup
|
||||
*/
|
||||
export const stopBackupResponse = type({
|
||||
success: "boolean",
|
||||
});
|
||||
|
||||
export type StopBackupDto = typeof stopBackupResponse.infer;
|
||||
|
||||
export const stopBackupDto = describeRoute({
|
||||
description: "Stop a backup that is currently in progress",
|
||||
operationId: "stopBackup",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backup stopped successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(stopBackupResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "No backup is currently running for this schedule",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import cron from "node-cron";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
||||
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
||||
import { db } from "../../db/db";
|
||||
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||
import { restic } from "../../utils/restic";
|
||||
@@ -11,6 +11,8 @@ import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backu
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { serverEvents } from "../../core/events";
|
||||
|
||||
const runningBackups = new Map<number, AbortController>();
|
||||
|
||||
const calculateNextRun = (cronExpression: string): number => {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
@@ -160,6 +162,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schedule.lastBackupStatus === "in_progress") {
|
||||
logger.info(`Backup schedule ${scheduleId} is already in progress. Skipping execution.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.id, schedule.volumeId),
|
||||
});
|
||||
@@ -190,9 +197,12 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
|
||||
await db
|
||||
.update(backupSchedulesTable)
|
||||
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now() })
|
||||
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null })
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
|
||||
const abortController = new AbortController();
|
||||
runningBackups.set(scheduleId, abortController);
|
||||
|
||||
try {
|
||||
const volumePath = getVolumePath(volume);
|
||||
|
||||
@@ -200,8 +210,10 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
exclude?: string[];
|
||||
include?: string[];
|
||||
tags?: string[];
|
||||
signal?: AbortSignal;
|
||||
} = {
|
||||
tags: [schedule.id.toString()],
|
||||
signal: abortController.signal,
|
||||
};
|
||||
|
||||
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
|
||||
@@ -212,7 +224,17 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
backupOptions.include = schedule.includePatterns;
|
||||
}
|
||||
|
||||
await restic.backup(repository.config, volumePath, backupOptions);
|
||||
await restic.backup(repository.config, volumePath, {
|
||||
...backupOptions,
|
||||
onProgress: (progress) => {
|
||||
serverEvents.emit("backup:progress", {
|
||||
scheduleId,
|
||||
volumeName: volume.name,
|
||||
repositoryName: repository.name,
|
||||
...progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (schedule.retentionPolicy) {
|
||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
||||
@@ -259,6 +281,8 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
runningBackups.delete(scheduleId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,6 +312,34 @@ const getScheduleForVolume = async (volumeId: number) => {
|
||||
return schedule ?? null;
|
||||
};
|
||||
|
||||
const stopBackup = async (scheduleId: number) => {
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(backupSchedulesTable)
|
||||
.set({
|
||||
lastBackupStatus: "error",
|
||||
lastBackupError: "Backup was stopped by user",
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
|
||||
const abortController = runningBackups.get(scheduleId);
|
||||
if (!abortController) {
|
||||
throw new ConflictError("No backup is currently running for this schedule");
|
||||
}
|
||||
|
||||
logger.info(`Stopping backup for schedule ${scheduleId}`);
|
||||
|
||||
abortController.abort();
|
||||
};
|
||||
|
||||
export const backupsService = {
|
||||
listSchedules,
|
||||
getSchedule,
|
||||
@@ -297,4 +349,5 @@ export const backupsService = {
|
||||
executeBackup,
|
||||
getSchedulesToExecute,
|
||||
getScheduleForVolume,
|
||||
stopBackup,
|
||||
};
|
||||
|
||||
@@ -19,11 +19,29 @@ export const eventsController = new Hono().get("/", (c) => {
|
||||
});
|
||||
};
|
||||
|
||||
const onBackupProgress = (data: {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
secondsElapsed: number;
|
||||
percentDone: number;
|
||||
totalFiles: number;
|
||||
filesDone: number;
|
||||
totalBytes: number;
|
||||
bytesDone: number;
|
||||
currentFiles: string[];
|
||||
}) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify(data),
|
||||
event: "backup:progress",
|
||||
});
|
||||
};
|
||||
|
||||
const onBackupCompleted = (data: {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
status: "success" | "error";
|
||||
status: "success" | "error" | "stopped";
|
||||
}) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify(data),
|
||||
@@ -53,6 +71,7 @@ export const eventsController = new Hono().get("/", (c) => {
|
||||
};
|
||||
|
||||
serverEvents.on("backup:started", onBackupStarted);
|
||||
serverEvents.on("backup:progress", onBackupProgress);
|
||||
serverEvents.on("backup:completed", onBackupCompleted);
|
||||
serverEvents.on("volume:mounted", onVolumeMounted);
|
||||
serverEvents.on("volume:unmounted", onVolumeUnmounted);
|
||||
@@ -64,6 +83,7 @@ export const eventsController = new Hono().get("/", (c) => {
|
||||
logger.info("Client disconnected from SSE endpoint");
|
||||
keepAlive = false;
|
||||
serverEvents.off("backup:started", onBackupStarted);
|
||||
serverEvents.off("backup:progress", onBackupProgress);
|
||||
serverEvents.off("backup:completed", onBackupCompleted);
|
||||
serverEvents.off("volume:mounted", onVolumeMounted);
|
||||
serverEvents.off("volume:unmounted", onVolumeUnmounted);
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
import { Hono } from "hono";
|
||||
import { systemInfoDto, type SystemInfoDto } from "./system.dto";
|
||||
import { validator } from "hono-openapi";
|
||||
import {
|
||||
downloadResticPasswordBodySchema,
|
||||
downloadResticPasswordDto,
|
||||
systemInfoDto,
|
||||
type SystemInfoDto,
|
||||
} from "./system.dto";
|
||||
import { systemService } from "./system.service";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
import { RESTIC_PASS_FILE } from "../../core/constants";
|
||||
import { db } from "../../db/db";
|
||||
import { usersTable } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const systemController = new Hono().get("/info", systemInfoDto, async (c) => {
|
||||
const info = await systemService.getSystemInfo();
|
||||
export const systemController = new Hono()
|
||||
.get("/info", systemInfoDto, async (c) => {
|
||||
const info = await systemService.getSystemInfo();
|
||||
|
||||
return c.json<SystemInfoDto>(info, 200);
|
||||
});
|
||||
return c.json<SystemInfoDto>(info, 200);
|
||||
})
|
||||
.post(
|
||||
"/restic-password",
|
||||
downloadResticPasswordDto,
|
||||
requireAuth,
|
||||
validator("json", downloadResticPasswordBodySchema),
|
||||
async (c) => {
|
||||
const user = c.get("user");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, user.id));
|
||||
|
||||
if (!dbUser) {
|
||||
return c.json({ message: "User not found" }, 401);
|
||||
}
|
||||
|
||||
const isValid = await Bun.password.verify(body.password, dbUser.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
return c.json({ message: "Incorrect password" }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const file = Bun.file(RESTIC_PASS_FILE);
|
||||
const content = await file.text();
|
||||
|
||||
await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, user.id));
|
||||
|
||||
c.header("Content-Type", "text/plain");
|
||||
c.header("Content-Disposition", 'attachment; filename="restic.pass"');
|
||||
|
||||
return c.text(content);
|
||||
} catch (_error) {
|
||||
return c.json({ message: "Failed to read Restic password file" }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,3 +26,23 @@ export const systemInfoDto = describeRoute({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadResticPasswordBodySchema = type({
|
||||
password: "string",
|
||||
});
|
||||
|
||||
export const downloadResticPasswordDto = describeRoute({
|
||||
description: "Download the Restic password file for backup recovery. Requires password re-authentication.",
|
||||
tags: ["System"],
|
||||
operationId: "downloadResticPassword",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Restic password file content",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -130,11 +130,10 @@ const getVolume = async (name: string) => {
|
||||
|
||||
let statfs: Partial<StatFs> = {};
|
||||
if (volume.status === "mounted") {
|
||||
statfs = await withTimeout(getStatFs(getVolumePath(volume)), OPERATION_TIMEOUT, "getStatFs")
|
||||
.catch((error) => {
|
||||
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
||||
return {};
|
||||
});
|
||||
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
|
||||
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
return { volume, statfs };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { throttle } from "es-toolkit";
|
||||
import type { RepositoryConfig } from "@ironmount/schemas/restic";
|
||||
import { type } from "arktype";
|
||||
import { $ } from "bun";
|
||||
@@ -8,6 +9,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants";
|
||||
import { logger } from "./logger";
|
||||
import { cryptoUtils } from "./crypto";
|
||||
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
||||
import { safeSpawn } from "./spawn";
|
||||
|
||||
const backupOutputSchema = type({
|
||||
message_type: "'summary'",
|
||||
@@ -80,7 +82,7 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||
|
||||
const buildEnv = async (config: RepositoryConfig) => {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -111,10 +113,29 @@ const init = async (config: RepositoryConfig) => {
|
||||
return { success: true, error: null };
|
||||
};
|
||||
|
||||
const backupProgressSchema = type({
|
||||
message_type: "'status'",
|
||||
seconds_elapsed: "number",
|
||||
percent_done: "number",
|
||||
total_files: "number",
|
||||
files_done: "number",
|
||||
total_bytes: "number",
|
||||
bytes_done: "number",
|
||||
current_files: "string[]",
|
||||
});
|
||||
|
||||
export type BackupProgress = typeof backupProgressSchema.infer;
|
||||
|
||||
const backup = async (
|
||||
config: RepositoryConfig,
|
||||
source: string,
|
||||
options?: { exclude?: string[]; include?: string[]; tags?: string[] },
|
||||
options?: {
|
||||
exclude?: string[];
|
||||
include?: string[];
|
||||
tags?: string[];
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: BackupProgress) => void;
|
||||
},
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
@@ -148,28 +169,60 @@ const backup = async (
|
||||
|
||||
args.push("--json");
|
||||
|
||||
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
const logData = throttle((data: string) => {
|
||||
logger.info(data.trim());
|
||||
}, 5000);
|
||||
|
||||
if (includeFile) {
|
||||
await fs.unlink(includeFile).catch(() => {});
|
||||
}
|
||||
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 = "";
|
||||
|
||||
const res = await safeSpawn({
|
||||
command: "restic",
|
||||
args,
|
||||
env,
|
||||
signal: options?.signal,
|
||||
onStdout: (data) => {
|
||||
stdout = data;
|
||||
logData(data);
|
||||
|
||||
if (options?.onProgress) {
|
||||
streamProgress(data);
|
||||
}
|
||||
},
|
||||
onStderr: (error) => {
|
||||
logger.error(error.trim());
|
||||
},
|
||||
finally: async () => {
|
||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||
},
|
||||
});
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||
throw new Error(`Restic backup failed: ${res.stderr}`);
|
||||
}
|
||||
|
||||
// res is a succession of JSON objects, we need to parse the last one which contains the summary
|
||||
const stdout = res.text();
|
||||
const outputLines = stdout.trim().split("\n");
|
||||
const lastLine = outputLines[outputLines.length - 1];
|
||||
const lastLine = stdout.trim();
|
||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||
|
||||
const result = backupOutputSchema(resSummary);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
logger.error(`Restic backup output validation failed: ${result}`);
|
||||
|
||||
throw new Error(`Restic backup output validation failed: ${result}`);
|
||||
}
|
||||
|
||||
@@ -334,7 +387,6 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
||||
args.push("--prune");
|
||||
args.push("--json");
|
||||
|
||||
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
@@ -429,7 +481,7 @@ const unlock = async (config: RepositoryConfig) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const res = await $`restic unlock --repo ${repoUrl} --json`.env(env).nothrow();
|
||||
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||
@@ -465,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}`);
|
||||
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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
@@ -76,6 +77,7 @@
|
||||
"dockerode": "^4.0.8",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"es-toolkit": "^1.41.0",
|
||||
"hono": "^4.9.2",
|
||||
"hono-openapi": "^1.1.0",
|
||||
"http-errors-enhanced": "^3.0.2",
|
||||
@@ -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-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
@@ -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-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=="],
|
||||
|
||||
@@ -1484,6 +1488,10 @@
|
||||
|
||||
"@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
@@ -1594,6 +1602,8 @@
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
|
||||
|
||||
"recharts/es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
|
||||
|
||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
@@ -1660,6 +1670,8 @@
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||
|
||||
@@ -34,4 +34,6 @@ services:
|
||||
ports:
|
||||
- "4096:4096"
|
||||
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