mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
16 Commits
v0.6.0-alp
...
v0.6.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd36397346 | ||
|
|
2ec8d4c1dd | ||
|
|
4b981bdcac | ||
|
|
5e908dc945 | ||
|
|
5f35cfd4c2 | ||
|
|
1152939373 | ||
|
|
94398f81bf | ||
|
|
db0d153610 | ||
|
|
5ff48f4d5d | ||
|
|
ffca433a43 | ||
|
|
4389029ba5 | ||
|
|
927db77f60 | ||
|
|
3e80850396 | ||
|
|
5f620b4c45 | ||
|
|
3abf8ab12d | ||
|
|
b5ba03da3d |
@@ -23,3 +23,4 @@
|
||||
!LICENSE
|
||||
!NOTICES.md
|
||||
!LICENSES/**
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -232,7 +232,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||
onSelectionChange={handleSelectionChange}
|
||||
withCheckboxes={true}
|
||||
foldersOnly={true}
|
||||
className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
|
||||
className="max-w-2xs xs:max-w-screen flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
|
||||
/>
|
||||
{selectedPaths.size > 0 && (
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'",
|
||||
@@ -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,55 @@ 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);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||
throw new Error(`Restic backup failed: ${res.stderr}`);
|
||||
}
|
||||
let stdout = "";
|
||||
|
||||
// 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];
|
||||
await safeSpawn({
|
||||
command: "restic",
|
||||
args,
|
||||
env,
|
||||
signal: options?.signal,
|
||||
onStdout: (data) => {
|
||||
stdout = data;
|
||||
logData(data);
|
||||
|
||||
if (options?.onProgress) {
|
||||
streamProgress(data);
|
||||
}
|
||||
},
|
||||
onStderr: (error) => {
|
||||
logger.error(error.trim());
|
||||
},
|
||||
finally: async () => {
|
||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||
},
|
||||
});
|
||||
|
||||
const lastLine = stdout.trim();
|
||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||
|
||||
const result = backupOutputSchema(resSummary);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
logger.error(`Restic backup output validation failed: ${result}`);
|
||||
|
||||
throw new Error(`Restic backup output validation failed: ${result}`);
|
||||
}
|
||||
|
||||
@@ -334,7 +382,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 +476,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 +512,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
||||
};
|
||||
}
|
||||
|
||||
const hasErrors = stdout.includes("error") || stdout.includes("Fatal");
|
||||
const hasErrors = stdout.includes("Fatal");
|
||||
|
||||
logger.info(`Restic check completed for repository: ${repoUrl}`);
|
||||
return {
|
||||
|
||||
56
apps/server/src/utils/spawn.ts
Normal file
56
apps/server/src/utils/spawn.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
interface Params {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
onStdout?: (data: string) => void;
|
||||
onStderr?: (error: string) => void;
|
||||
onError?: (error: Error) => Promise<void> | void;
|
||||
onClose?: (code: number | null) => Promise<void> | void;
|
||||
finally?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const safeSpawn = (params: Params) => {
|
||||
const { command, args, env = {}, signal, ...callbacks } = params;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env: { ...process.env, ...env },
|
||||
signal: signal,
|
||||
});
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
if (callbacks.onStdout) {
|
||||
callbacks.onStdout(data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
if (callbacks.onStderr) {
|
||||
callbacks.onStderr(data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", async (error) => {
|
||||
if (callbacks.onError) {
|
||||
await callbacks.onError(error);
|
||||
}
|
||||
if (callbacks.finally) {
|
||||
await callbacks.finally();
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on("close", async (code) => {
|
||||
if (callbacks.onClose) {
|
||||
await callbacks.onClose(code);
|
||||
}
|
||||
if (callbacks.finally) {
|
||||
await callbacks.finally();
|
||||
}
|
||||
resolve(code);
|
||||
});
|
||||
});
|
||||
};
|
||||
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=="],
|
||||
|
||||
Reference in New Issue
Block a user