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
|
!LICENSE
|
||||||
!NOTICES.md
|
!NOTICES.md
|
||||||
!LICENSES/**
|
!LICENSES/**
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ import {
|
|||||||
updateBackupSchedule,
|
updateBackupSchedule,
|
||||||
getBackupScheduleForVolume,
|
getBackupScheduleForVolume,
|
||||||
runBackupNow,
|
runBackupNow,
|
||||||
|
stopBackup,
|
||||||
getSystemInfo,
|
getSystemInfo,
|
||||||
|
downloadResticPassword,
|
||||||
} from "../sdk.gen";
|
} from "../sdk.gen";
|
||||||
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
@@ -93,7 +95,11 @@ import type {
|
|||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
RunBackupNowData,
|
RunBackupNowData,
|
||||||
RunBackupNowResponse,
|
RunBackupNowResponse,
|
||||||
|
StopBackupData,
|
||||||
|
StopBackupResponse,
|
||||||
GetSystemInfoData,
|
GetSystemInfoData,
|
||||||
|
DownloadResticPasswordData,
|
||||||
|
DownloadResticPasswordResponse,
|
||||||
} from "../types.gen";
|
} from "../types.gen";
|
||||||
import { client as _heyApiClient } from "../client.gen";
|
import { client as _heyApiClient } from "../client.gen";
|
||||||
|
|
||||||
@@ -1104,6 +1110,45 @@ export const runBackupNowMutation = (
|
|||||||
return mutationOptions;
|
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);
|
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey("getSystemInfo", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1123,3 +1168,47 @@ export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => {
|
|||||||
queryKey: getSystemInfoQueryKey(options),
|
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,
|
GetBackupScheduleForVolumeResponses,
|
||||||
RunBackupNowData,
|
RunBackupNowData,
|
||||||
RunBackupNowResponses,
|
RunBackupNowResponses,
|
||||||
|
StopBackupData,
|
||||||
|
StopBackupResponses,
|
||||||
|
StopBackupErrors,
|
||||||
GetSystemInfoData,
|
GetSystemInfoData,
|
||||||
GetSystemInfoResponses,
|
GetSystemInfoResponses,
|
||||||
|
DownloadResticPasswordData,
|
||||||
|
DownloadResticPasswordResponses,
|
||||||
} from "./types.gen";
|
} from "./types.gen";
|
||||||
import { client as _heyApiClient } from "./client.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
|
* Get system information including available capabilities
|
||||||
*/
|
*/
|
||||||
@@ -541,3 +556,19 @@ export const getSystemInfo = <ThrowOnError extends boolean = false>(
|
|||||||
...options,
|
...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;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user?: {
|
user?: {
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
@@ -44,6 +45,7 @@ export type LoginResponses = {
|
|||||||
message: string;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user?: {
|
user?: {
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
@@ -85,6 +87,7 @@ export type GetMeResponses = {
|
|||||||
message: string;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user?: {
|
user?: {
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
@@ -149,6 +152,8 @@ export type ListVolumesResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -175,6 +180,7 @@ export type ListVolumesResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -196,6 +202,8 @@ export type CreateVolumeData = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -222,6 +230,7 @@ export type CreateVolumeData = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -241,6 +250,8 @@ export type CreateVolumeResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -267,6 +278,7 @@ export type CreateVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -288,6 +300,8 @@ export type TestConnectionData = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -314,6 +328,7 @@ export type TestConnectionData = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -386,6 +401,8 @@ export type GetVolumeResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -412,6 +429,7 @@ export type GetVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -435,6 +453,8 @@ export type UpdateVolumeData = {
|
|||||||
config?:
|
config?:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -461,6 +481,7 @@ export type UpdateVolumeData = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -488,6 +509,8 @@ export type UpdateVolumeResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -514,6 +537,7 @@ export type UpdateVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -962,12 +986,11 @@ export type DoctorRepositoryResponses = {
|
|||||||
* Doctor operation completed
|
* Doctor operation completed
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
message: string;
|
|
||||||
steps: Array<{
|
steps: Array<{
|
||||||
|
error: string | null;
|
||||||
|
output: string | null;
|
||||||
step: string;
|
step: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
|
||||||
output?: string;
|
|
||||||
}>;
|
}>;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
};
|
};
|
||||||
@@ -1036,6 +1059,8 @@ export type ListBackupSchedulesResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -1062,6 +1087,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -1219,6 +1245,8 @@ export type GetBackupScheduleResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -1245,6 +1273,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -1383,6 +1412,8 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -1409,6 +1440,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -1448,6 +1480,33 @@ export type RunBackupNowResponses = {
|
|||||||
|
|
||||||
export type RunBackupNowResponse = RunBackupNowResponses[keyof 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 = {
|
export type GetSystemInfoData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -1468,6 +1527,24 @@ export type GetSystemInfoResponses = {
|
|||||||
|
|
||||||
export type GetSystemInfoResponse = GetSystemInfoResponses[keyof 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 = {
|
export type ClientOptions = {
|
||||||
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
--breakpoint-xs: 32rem;
|
||||||
--font-sans:
|
--font-sans:
|
||||||
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
"Noto Color Emoji";
|
"Noto Color Emoji";
|
||||||
@@ -12,16 +13,16 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@apply bg-white dark:bg-[#131313];
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
body {
|
||||||
color-scheme: dark;
|
@apply bg-[#131313];
|
||||||
}
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
@@ -107,6 +108,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
--background: #131313;
|
--background: #131313;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: #131313;
|
--card: #131313;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type AuthLayoutProps = {
|
|||||||
|
|
||||||
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex mt-[25%] lg:mt-0 lg:min-h-screen">
|
||||||
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -26,7 +26,7 @@ export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="hidden lg:block lg:flex-1 dither-xl bg-cover bg-center"
|
className="hidden lg:block lg:flex-1 dither-lg bg-cover bg-center"
|
||||||
style={{ backgroundImage: "url(/images/background.jpg)" }}
|
style={{ backgroundImage: "url(/images/background.jpg)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { LifeBuoy } from "lucide-react";
|
import { LifeBuoy } from "lucide-react";
|
||||||
import { Outlet, useNavigate } from "react-router";
|
import { Outlet, redirect, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { appContext } from "~/context";
|
import { appContext } from "~/context";
|
||||||
@@ -16,6 +16,11 @@ export const clientMiddleware = [authMiddleware];
|
|||||||
|
|
||||||
export async function clientLoader({ context }: Route.LoaderArgs) {
|
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||||
const ctx = context.get(appContext);
|
const ctx = context.get(appContext);
|
||||||
|
|
||||||
|
if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
|
||||||
|
throw redirect("/download-recovery-key");
|
||||||
|
}
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
</header>
|
</header>
|
||||||
<div className="main-content flex-1 overflow-y-auto">
|
<div className="main-content flex-1 overflow-y-auto">
|
||||||
<GridBackground>
|
<GridBackground>
|
||||||
<main className="flex flex-col p-2 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</GridBackground>
|
</GridBackground>
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{isOn ? enabledLabel : disabledLabel}</span>
|
<span>{isOn ? enabledLabel : disabledLabel}</span>
|
||||||
<Switch disabled={disabled} checked={isOn} onCheckedChange={toggle} />
|
<Switch
|
||||||
|
disabled={disabled}
|
||||||
|
checked={isOn}
|
||||||
|
onCheckedChange={toggle}
|
||||||
|
aria-label={isOn ? `Toggle ${enabledLabel}` : `Toggle ${disabledLabel}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/api-client/types.gen";
|
|||||||
import { ByteSize } from "~/components/bytes-size";
|
import { ByteSize } from "~/components/bytes-size";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||||
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
|
import { formatDuration } from "~/utils/utils";
|
||||||
|
|
||||||
type Snapshot = ListSnapshotsResponse[number];
|
type Snapshot = ListSnapshotsResponse[number];
|
||||||
|
|
||||||
@@ -62,9 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
|||||||
<TableCell className="hidden md:table-cell">
|
<TableCell className="hidden md:table-cell">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||||
{formatSnapshotDuration(snapshot.duration / 1000)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
|
<span
|
||||||
|
aria-label={status}
|
||||||
|
className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const alertVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: "bg-background text-foreground",
|
default: "bg-background text-foreground",
|
||||||
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
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: {
|
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"
|
| "connected"
|
||||||
| "heartbeat"
|
| "heartbeat"
|
||||||
| "backup:started"
|
| "backup:started"
|
||||||
|
| "backup:progress"
|
||||||
| "backup:completed"
|
| "backup:completed"
|
||||||
| "volume:mounted"
|
| "volume:mounted"
|
||||||
| "volume:unmounted"
|
| "volume:unmounted"
|
||||||
| "volume:updated";
|
| "volume:updated";
|
||||||
|
|
||||||
interface BackupEvent {
|
export interface BackupEvent {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status?: "success" | "error";
|
status?: "success" | "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VolumeEvent {
|
export interface BackupProgressEvent {
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
seconds_elapsed: number;
|
||||||
|
percent_done: number;
|
||||||
|
total_files: number;
|
||||||
|
files_done: number;
|
||||||
|
total_bytes: number;
|
||||||
|
bytes_done: number;
|
||||||
|
current_files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeEvent {
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +65,14 @@ export function useServerEvents() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("backup:progress", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as BackupProgressEvent;
|
||||||
|
|
||||||
|
handlersRef.current.get("backup:progress")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
eventSource.addEventListener("backup:completed", (e) => {
|
eventSource.addEventListener("backup:completed", (e) => {
|
||||||
const data = JSON.parse(e.data) as BackupEvent;
|
const data = JSON.parse(e.data) as BackupEvent;
|
||||||
console.log("[SSE] Backup completed:", data);
|
console.log("[SSE] Backup completed:", data);
|
||||||
|
|||||||
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({
|
const login = useMutation({
|
||||||
...loginMutation(),
|
...loginMutation(),
|
||||||
onSuccess: async () => {
|
onSuccess: async (data) => {
|
||||||
navigate("/volumes");
|
if (data.user && !data.user.hasDownloadedResticPassword) {
|
||||||
|
navigate("/download-recovery-key");
|
||||||
|
} else {
|
||||||
|
navigate("/volumes");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function OnboardingPage() {
|
|||||||
...registerMutation(),
|
...registerMutation(),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success("Admin user created successfully!");
|
toast.success("Admin user created successfully!");
|
||||||
navigate("/volumes");
|
navigate("/download-recovery-key");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error(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}
|
onSelectionChange={handleSelectionChange}
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
foldersOnly={true}
|
foldersOnly={true}
|
||||||
className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
|
className="max-w-2xs xs:max-w-screen flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
|
||||||
/>
|
/>
|
||||||
{selectedPaths.size > 0 && (
|
{selectedPaths.size > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
@@ -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 { useMemo, useState } from "react";
|
||||||
import { OnOff } from "~/components/onoff";
|
import { OnOff } from "~/components/onoff";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -13,17 +13,20 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import type { BackupSchedule } from "~/lib/types";
|
import type { BackupSchedule } from "~/lib/types";
|
||||||
|
import { BackupProgressCard } from "./backup-progress-card";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
schedule: BackupSchedule;
|
schedule: BackupSchedule;
|
||||||
handleToggleEnabled: (enabled: boolean) => void;
|
handleToggleEnabled: (enabled: boolean) => void;
|
||||||
handleRunBackupNow: () => void;
|
handleRunBackupNow: () => void;
|
||||||
|
handleStopBackup: () => void;
|
||||||
handleDeleteSchedule: () => void;
|
handleDeleteSchedule: () => void;
|
||||||
setIsEditMode: (isEdit: boolean) => void;
|
setIsEditMode: (isEdit: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScheduleSummary = (props: Props) => {
|
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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
@@ -75,16 +78,17 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Button
|
{schedule.lastBackupStatus === "in_progress" ? (
|
||||||
variant="default"
|
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
|
||||||
size="sm"
|
<Square className="h-4 w-4 mr-2" />
|
||||||
onClick={handleRunBackupNow}
|
<span className="sm:inline">Stop backup</span>
|
||||||
disabled={schedule.lastBackupStatus === "in_progress"}
|
</Button>
|
||||||
className="w-full sm:w-auto"
|
) : (
|
||||||
>
|
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
|
||||||
<Play className="h-4 w-4 mr-2" />
|
<Play className="h-4 w-4 mr-2" />
|
||||||
<span className="sm:inline">Backup now</span>
|
<span className="sm:inline">Backup now</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
<span className="sm:inline">Edit schedule</span>
|
<span className="sm:inline">Edit schedule</span>
|
||||||
@@ -141,6 +145,8 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
|
||||||
|
|
||||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
deleteBackupScheduleMutation,
|
deleteBackupScheduleMutation,
|
||||||
listSnapshotsOptions,
|
listSnapshotsOptions,
|
||||||
updateBackupScheduleMutation,
|
updateBackupScheduleMutation,
|
||||||
|
stopBackupMutation,
|
||||||
} from "~/api-client/@tanstack/react-query.gen";
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/lib/errors";
|
import { parseError } from "~/lib/errors";
|
||||||
import { getCronExpression } from "~/utils/utils";
|
import { getCronExpression } from "~/utils/utils";
|
||||||
@@ -44,9 +45,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||||
|
|
||||||
const { data: schedule } = useQuery({
|
const { data: schedule } = useQuery({
|
||||||
...getBackupScheduleOptions({
|
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||||
path: { scheduleId: params.id },
|
|
||||||
}),
|
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
@@ -57,13 +56,10 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
isLoading,
|
isLoading,
|
||||||
failureReason,
|
failureReason,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
...listSnapshotsOptions({
|
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
|
||||||
path: { name: schedule.repository.name },
|
|
||||||
query: { backupId: schedule.id.toString() },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const upsertSchedule = useMutation({
|
const updateSchedule = useMutation({
|
||||||
...updateBackupScheduleMutation(),
|
...updateBackupScheduleMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Backup schedule saved successfully");
|
toast.success("Backup schedule saved successfully");
|
||||||
@@ -82,9 +78,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
toast.success("Backup started successfully");
|
toast.success("Backup started successfully");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to start backup", {
|
toast.error("Failed to start backup", { description: parseError(error)?.message });
|
||||||
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");
|
navigate("/backups");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to delete backup schedule", {
|
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
|
||||||
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.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
|
||||||
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
||||||
|
|
||||||
upsertSchedule.mutate({
|
updateSchedule.mutate({
|
||||||
path: { scheduleId: schedule.id.toString() },
|
path: { scheduleId: schedule.id.toString() },
|
||||||
body: {
|
body: {
|
||||||
repositoryId: formValues.repositoryId,
|
repositoryId: formValues.repositoryId,
|
||||||
@@ -128,9 +130,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleEnabled = (enabled: boolean) => {
|
const handleToggleEnabled = (enabled: boolean) => {
|
||||||
if (!schedule) return;
|
updateSchedule.mutate({
|
||||||
|
|
||||||
upsertSchedule.mutate({
|
|
||||||
path: { scheduleId: schedule.id.toString() },
|
path: { scheduleId: schedule.id.toString() },
|
||||||
body: {
|
body: {
|
||||||
repositoryId: schedule.repositoryId,
|
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) {
|
if (isEditMode) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
|
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
|
||||||
<div className="flex justify-end mt-4 gap-2">
|
<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
|
Update schedule
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
<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">
|
<div className="flex flex-col gap-6">
|
||||||
<ScheduleSummary
|
<ScheduleSummary
|
||||||
handleToggleEnabled={handleToggleEnabled}
|
handleToggleEnabled={handleToggleEnabled}
|
||||||
handleRunBackupNow={handleRunBackupNow}
|
handleRunBackupNow={() => runBackupNow.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||||
handleDeleteSchedule={handleDeleteSchedule}
|
handleStopBackup={() => stopBackup.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||||
|
handleDeleteSchedule={() => deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||||
setIsEditMode={setIsEditMode}
|
setIsEditMode={setIsEditMode}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
import { redirect, useNavigate, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
deleteRepositoryMutation,
|
deleteRepositoryMutation,
|
||||||
|
doctorRepositoryMutation,
|
||||||
getRepositoryOptions,
|
getRepositoryOptions,
|
||||||
listSnapshotsOptions,
|
listSnapshotsOptions,
|
||||||
} from "~/api-client/@tanstack/react-query.gen";
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
@@ -24,6 +25,7 @@ import { cn } from "~/lib/utils";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { RepositoryInfoTabContent } from "../tabs/info";
|
import { RepositoryInfoTabContent } from "../tabs/info";
|
||||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
@@ -38,10 +40,13 @@ export function meta({ params }: Route.MetaArgs) {
|
|||||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
const repository = await getRepository({ path: { name: params.name ?? "" } });
|
const repository = await getRepository({ path: { name: params.name ?? "" } });
|
||||||
if (repository.data) return repository.data;
|
if (repository.data) return repository.data;
|
||||||
|
|
||||||
|
return redirect("/repositories");
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
|
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { name } = useParams<{ name: string }>();
|
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
@@ -50,17 +55,15 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
const activeTab = searchParams.get("tab") || "info";
|
const activeTab = searchParams.get("tab") || "info";
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...getRepositoryOptions({ path: { name: name ?? "" } }),
|
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (name) {
|
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
|
||||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } }));
|
}, [queryClient, data.name]);
|
||||||
}
|
|
||||||
}, [name, queryClient]);
|
|
||||||
|
|
||||||
const deleteRepo = useMutation({
|
const deleteRepo = useMutation({
|
||||||
...deleteRepositoryMutation(),
|
...deleteRepositoryMutation(),
|
||||||
@@ -75,39 +78,78 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const doctorMutation = useMutation({
|
||||||
|
...doctorRepositoryMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data) {
|
||||||
|
setShowDoctorResults(true);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
toast.success("Repository doctor completed successfully");
|
||||||
|
} else {
|
||||||
|
toast.warning("Doctor completed with some issues", {
|
||||||
|
description: "Check the details for more information",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to run doctor", {
|
||||||
|
description: parseError(error)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleConfirmDelete = () => {
|
const handleConfirmDelete = () => {
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
deleteRepo.mutate({ path: { name: name ?? "" } });
|
deleteRepo.mutate({ path: { name: data.name } });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!name) {
|
const getStepLabel = (step: string) => {
|
||||||
return <div>Repository not found</div>;
|
switch (step) {
|
||||||
}
|
case "unlock":
|
||||||
|
return "Unlock Repository";
|
||||||
if (!data) {
|
case "check":
|
||||||
return <div>Loading...</div>;
|
return "Check Repository";
|
||||||
}
|
case "repair_index":
|
||||||
|
return "Repair Index";
|
||||||
|
case "recheck":
|
||||||
|
return "Re-check Repository";
|
||||||
|
default:
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||||
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
|
<span
|
||||||
<span
|
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
||||||
className={cn(
|
"bg-green-500/10 text-green-500": data.status === "healthy",
|
||||||
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
|
"bg-red-500/10 text-red-500": data.status === "error",
|
||||||
{
|
})}
|
||||||
"bg-green-500/10 text-green-500": data.status === "healthy",
|
>
|
||||||
"bg-red-500/10 text-red-500": data.status === "error",
|
{data.status || "unknown"}
|
||||||
},
|
</span>
|
||||||
)}
|
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
|
||||||
>
|
|
||||||
{data.status || "unknown"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => doctorMutation.mutate({ path: { name: data.name } })}
|
||||||
|
disabled={doctorMutation.isPending}
|
||||||
|
variant={"outline"}
|
||||||
|
>
|
||||||
|
{doctorMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Running Doctor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Run Doctor"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
|
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
@@ -132,8 +174,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete the repository <strong>{name}</strong>? This action cannot be undone and
|
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||||
will remove all backup data.
|
and will remove all backup data.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
@@ -147,6 +189,46 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
|
||||||
|
<AlertDialogContent className="max-w-2xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
{doctorMutation.data && (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{doctorMutation.data.steps.map((step) => (
|
||||||
|
<div
|
||||||
|
key={step.step}
|
||||||
|
className={cn("border rounded-md p-3", {
|
||||||
|
"bg-green-500/10 border-green-500/20": step.success,
|
||||||
|
"bg-yellow-500/10 border-yellow-500/20": !step.success,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
|
||||||
|
<span
|
||||||
|
className={cn("text-xs px-2 py-1 rounded", {
|
||||||
|
"bg-green-500/20 text-green-500": step.success,
|
||||||
|
"bg-yellow-500/20 text-yellow-500": !step.success,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{step.success ? "Success" : "Warning"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,19 @@ export function meta({ params }: Route.MetaArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } });
|
const snapshot = await getSnapshotDetails({
|
||||||
|
path: { name: params.name, snapshotId: params.snapshotId },
|
||||||
|
});
|
||||||
if (snapshot.data) return snapshot.data;
|
if (snapshot.data) return snapshot.data;
|
||||||
|
|
||||||
return redirect("/repositories");
|
return redirect("/repositories");
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
|
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>();
|
const { name, snapshotId } = useParams<{
|
||||||
|
name: string;
|
||||||
|
snapshotId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...listSnapshotFilesOptions({
|
...listSnapshotFilesOptions({
|
||||||
@@ -64,11 +69,11 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Snapshot ID:</span>
|
<span className="text-muted-foreground">Snapshot ID:</span>
|
||||||
<p className="font-mono">{data.snapshot.id}</p>
|
<p className="font-mono break-all">{data.snapshot.id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Short ID:</span>
|
<span className="text-muted-foreground">Short ID:</span>
|
||||||
<p className="font-mono">{data.snapshot.short_id}</p>
|
<p className="font-mono break-all">{data.snapshot.short_id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Hostname:</span>
|
<span className="text-muted-foreground">Hostname:</span>
|
||||||
@@ -82,7 +87,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||||||
<span className="text-muted-foreground">Paths:</span>
|
<span className="text-muted-foreground">Paths:</span>
|
||||||
<div className="space-y-1 mt-1">
|
<div className="space-y-1 mt-1">
|
||||||
{data.snapshot.paths.map((path) => (
|
{data.snapshot.paths.map((path) => (
|
||||||
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded">
|
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
|
||||||
{path}
|
{path}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,177 +1,63 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "~/components/ui/alert-dialog";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import type { Repository } from "~/lib/types";
|
import type { Repository } from "~/lib/types";
|
||||||
import { parseError } from "~/lib/errors";
|
|
||||||
import { doctorRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
|
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||||
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
|
||||||
|
|
||||||
const doctorMutation = useMutation({
|
|
||||||
...doctorRepositoryMutation(),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data) {
|
|
||||||
setShowDoctorResults(true);
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
toast.success("Repository doctor completed successfully");
|
|
||||||
} else {
|
|
||||||
toast.warning("Doctor completed with some issues", {
|
|
||||||
description: "Check the details for more information",
|
|
||||||
richColors: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Failed to run doctor", {
|
|
||||||
description: parseError(error)?.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDoctor = () => {
|
|
||||||
doctorMutation.mutate({ path: { name: repository.name } });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStepLabel = (step: string) => {
|
|
||||||
switch (step) {
|
|
||||||
case "unlock":
|
|
||||||
return "Unlock Repository";
|
|
||||||
case "check":
|
|
||||||
return "Check Repository";
|
|
||||||
case "repair_index":
|
|
||||||
return "Repair Index";
|
|
||||||
case "recheck":
|
|
||||||
return "Re-check Repository";
|
|
||||||
default:
|
|
||||||
return step;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Card className="p-6">
|
||||||
<Card className="p-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-6">
|
<div>
|
||||||
<div>
|
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
|
||||||
<p className="mt-1 text-sm">{repository.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
|
||||||
<p className="mt-1 text-sm">{repository.type}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
|
|
||||||
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
|
||||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Created At</div>
|
|
||||||
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
|
||||||
<p className="mt-1 text-sm">
|
|
||||||
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{repository.lastError && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
||||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
<p className="mt-1 text-sm">{repository.name}</p>
|
||||||
<Button onClick={handleDoctor} disabled={doctorMutation.isPending} variant={"outline"} size="sm">
|
|
||||||
{doctorMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Running Doctor...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Run Doctor"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
|
||||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||||
<div>
|
<p className="mt-1 text-sm">{repository.type}</p>
|
||||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-md p-4">
|
<div>
|
||||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
|
||||||
|
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||||
|
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Created At</div>
|
||||||
|
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
{repository.lastError && (
|
||||||
|
<div>
|
||||||
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<AlertDialogContent className="max-w-2xl">
|
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||||
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
|
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
)}
|
||||||
</AlertDialog>
|
<div>
|
||||||
</>
|
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||||
|
<div className="bg-muted/50 rounded-md p-4">
|
||||||
|
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { intervalToDuration } from "date-fns";
|
|
||||||
import { Database } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
|
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
@@ -15,18 +14,6 @@ type Props = {
|
|||||||
repository: Repository;
|
repository: Repository;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatSnapshotDuration = (seconds: number) => {
|
|
||||||
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (duration.days) parts.push(`${duration.days}d`);
|
|
||||||
if (duration.hours) parts.push(`${duration.hours}h`);
|
|
||||||
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
|
||||||
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
|
||||||
|
|
||||||
return parts.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { KeyRound, User } from "lucide-react";
|
import { Download, KeyRound, User } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
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 { 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 { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { appContext } from "~/context";
|
import { appContext } from "~/context";
|
||||||
@@ -30,6 +43,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||||
|
const [downloadPassword, setDownloadPassword] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const logout = useMutation({
|
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) => {
|
const handleChangePassword = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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 (
|
return (
|
||||||
<Card className="p-0 gap-0">
|
<Card className="p-0 gap-0">
|
||||||
<div className="border-b border-border/50 bg-card-header p-6">
|
<div className="border-b border-border/50 bg-card-header p-6">
|
||||||
@@ -143,6 +195,69 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,14 +118,12 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||||
<div>
|
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
||||||
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
</span>
|
||||||
</span>
|
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" style={{ colorScheme: "dark" }} className="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
@@ -52,7 +52,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<body>
|
<body className="dark">
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { layout, type RouteConfig, route } from "@react-router/dev/routes";
|
|||||||
export default [
|
export default [
|
||||||
route("onboarding", "./modules/auth/routes/onboarding.tsx"),
|
route("onboarding", "./modules/auth/routes/onboarding.tsx"),
|
||||||
route("login", "./modules/auth/routes/login.tsx"),
|
route("login", "./modules/auth/routes/login.tsx"),
|
||||||
|
route("download-recovery-key", "./modules/auth/routes/download-recovery-key.tsx"),
|
||||||
layout("./components/layout.tsx", [
|
layout("./components/layout.tsx", [
|
||||||
route("/", "./routes/root.tsx"),
|
route("/", "./routes/root.tsx"),
|
||||||
route("volumes", "./modules/volumes/routes/volumes.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 => {
|
export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => {
|
||||||
if (frequency === "hourly") {
|
if (frequency === "hourly") {
|
||||||
return "0 * * * *";
|
return "0 * * * *";
|
||||||
@@ -15,3 +17,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD
|
|||||||
|
|
||||||
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`;
|
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatDuration = (seconds: number) => {
|
||||||
|
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (duration.days) parts.push(`${duration.days}d`);
|
||||||
|
if (duration.hours) parts.push(`${duration.hours}h`);
|
||||||
|
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
||||||
|
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
||||||
|
|
||||||
|
return parts.join(" ");
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
"when": 1762095226041,
|
||||||
"tag": "0009_little_adam_warlock",
|
"tag": "0009_little_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1762610065889,
|
||||||
|
"tag": "0010_perfect_proemial_gods",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"dockerode": "^4.0.8",
|
"dockerode": "^4.0.8",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"drizzle-orm": "^0.44.6",
|
"drizzle-orm": "^0.44.6",
|
||||||
|
"es-toolkit": "^1.41.0",
|
||||||
"hono": "^4.9.2",
|
"hono": "^4.9.2",
|
||||||
"hono-openapi": "^1.1.0",
|
"hono-openapi": "^1.1.0",
|
||||||
"http-errors-enhanced": "^3.0.2",
|
"http-errors-enhanced": "^3.0.2",
|
||||||
|
|||||||
@@ -6,11 +6,23 @@ import type { TypedEmitter } from "tiny-typed-emitter";
|
|||||||
*/
|
*/
|
||||||
interface ServerEvents {
|
interface ServerEvents {
|
||||||
"backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void;
|
"backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void;
|
||||||
|
"backup:progress": (data: {
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
seconds_elapsed: number;
|
||||||
|
percent_done: number;
|
||||||
|
total_files: number;
|
||||||
|
files_done: number;
|
||||||
|
total_bytes: number;
|
||||||
|
bytes_done: number;
|
||||||
|
current_files: string[];
|
||||||
|
}) => void;
|
||||||
"backup:completed": (data: {
|
"backup:completed": (data: {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error";
|
status: "success" | "error" | "stopped";
|
||||||
}) => void;
|
}) => void;
|
||||||
"volume:mounted": (data: { volumeName: string }) => void;
|
"volume:mounted": (data: { volumeName: string }) => void;
|
||||||
"volume:unmounted": (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 }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
username: text().notNull().unique(),
|
username: text().notNull().unique(),
|
||||||
passwordHash: text("password_hash").notNull(),
|
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())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
updatedAt: int("updated_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>(
|
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,
|
201,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -64,7 +72,11 @@ export const authController = new Hono()
|
|||||||
return c.json<LoginDto>({
|
return c.json<LoginDto>({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Login successful",
|
message: "Login successful",
|
||||||
user: { id: user.id, username: user.username },
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const loginResponseSchema = type({
|
|||||||
user: type({
|
user: type({
|
||||||
id: "number",
|
id: "number",
|
||||||
username: "string",
|
username: "string",
|
||||||
|
hasDownloadedResticPassword: "boolean",
|
||||||
}).optional(),
|
}).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ declare module "hono" {
|
|||||||
user: {
|
user: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,15 @@ export class AuthService {
|
|||||||
expiresAt,
|
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 {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
user: { id: user.id, username: user.username },
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||||
|
},
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -109,6 +121,7 @@ export class AuthService {
|
|||||||
user: {
|
user: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
username: session.user.username,
|
username: session.user.username,
|
||||||
|
hasDownloadedResticPassword: session.user.hasDownloadedResticPassword,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: session.session.id,
|
id: session.session.id,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getBackupScheduleForVolumeDto,
|
getBackupScheduleForVolumeDto,
|
||||||
listBackupSchedulesDto,
|
listBackupSchedulesDto,
|
||||||
runBackupNowDto,
|
runBackupNowDto,
|
||||||
|
stopBackupDto,
|
||||||
updateBackupScheduleDto,
|
updateBackupScheduleDto,
|
||||||
updateBackupScheduleBody,
|
updateBackupScheduleBody,
|
||||||
type CreateBackupScheduleDto,
|
type CreateBackupScheduleDto,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
type GetBackupScheduleForVolumeResponseDto,
|
type GetBackupScheduleForVolumeResponseDto,
|
||||||
type ListBackupSchedulesResponseDto,
|
type ListBackupSchedulesResponseDto,
|
||||||
type RunBackupNowDto,
|
type RunBackupNowDto,
|
||||||
|
type StopBackupDto,
|
||||||
type UpdateBackupScheduleDto,
|
type UpdateBackupScheduleDto,
|
||||||
} from "./backups.dto";
|
} from "./backups.dto";
|
||||||
import { backupsService } from "./backups.service";
|
import { backupsService } from "./backups.service";
|
||||||
@@ -69,4 +71,11 @@ export const backupScheduleController = new Hono()
|
|||||||
});
|
});
|
||||||
|
|
||||||
return c.json<RunBackupNowDto>({ success: true }, 200);
|
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 { eq } from "drizzle-orm";
|
||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
import { CronExpressionParser } from "cron-parser";
|
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 { db } from "../../db/db";
|
||||||
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
@@ -11,6 +11,8 @@ import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backu
|
|||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
|
|
||||||
|
const runningBackups = new Map<number, AbortController>();
|
||||||
|
|
||||||
const calculateNextRun = (cronExpression: string): number => {
|
const calculateNextRun = (cronExpression: string): number => {
|
||||||
try {
|
try {
|
||||||
const interval = CronExpressionParser.parse(cronExpression, {
|
const interval = CronExpressionParser.parse(cronExpression, {
|
||||||
@@ -160,6 +162,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
return;
|
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({
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
where: eq(volumesTable.id, schedule.volumeId),
|
where: eq(volumesTable.id, schedule.volumeId),
|
||||||
});
|
});
|
||||||
@@ -190,9 +197,12 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
|
|
||||||
await db
|
await db
|
||||||
.update(backupSchedulesTable)
|
.update(backupSchedulesTable)
|
||||||
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now() })
|
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null })
|
||||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
runningBackups.set(scheduleId, abortController);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const volumePath = getVolumePath(volume);
|
const volumePath = getVolumePath(volume);
|
||||||
|
|
||||||
@@ -200,8 +210,10 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
include?: string[];
|
include?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
} = {
|
} = {
|
||||||
tags: [schedule.id.toString()],
|
tags: [schedule.id.toString()],
|
||||||
|
signal: abortController.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
|
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
|
||||||
@@ -212,7 +224,17 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
backupOptions.include = schedule.includePatterns;
|
backupOptions.include = schedule.includePatterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
await restic.backup(repository.config, volumePath, backupOptions);
|
await restic.backup(repository.config, volumePath, {
|
||||||
|
...backupOptions,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
serverEvents.emit("backup:progress", {
|
||||||
|
scheduleId,
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
...progress,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (schedule.retentionPolicy) {
|
if (schedule.retentionPolicy) {
|
||||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
||||||
@@ -259,6 +281,8 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
runningBackups.delete(scheduleId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,6 +312,34 @@ const getScheduleForVolume = async (volumeId: number) => {
|
|||||||
return schedule ?? null;
|
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 = {
|
export const backupsService = {
|
||||||
listSchedules,
|
listSchedules,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
@@ -297,4 +349,5 @@ export const backupsService = {
|
|||||||
executeBackup,
|
executeBackup,
|
||||||
getSchedulesToExecute,
|
getSchedulesToExecute,
|
||||||
getScheduleForVolume,
|
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: {
|
const onBackupCompleted = (data: {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error";
|
status: "success" | "error" | "stopped";
|
||||||
}) => {
|
}) => {
|
||||||
stream.writeSSE({
|
stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
@@ -53,6 +71,7 @@ export const eventsController = new Hono().get("/", (c) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
serverEvents.on("backup:started", onBackupStarted);
|
serverEvents.on("backup:started", onBackupStarted);
|
||||||
|
serverEvents.on("backup:progress", onBackupProgress);
|
||||||
serverEvents.on("backup:completed", onBackupCompleted);
|
serverEvents.on("backup:completed", onBackupCompleted);
|
||||||
serverEvents.on("volume:mounted", onVolumeMounted);
|
serverEvents.on("volume:mounted", onVolumeMounted);
|
||||||
serverEvents.on("volume:unmounted", onVolumeUnmounted);
|
serverEvents.on("volume:unmounted", onVolumeUnmounted);
|
||||||
@@ -64,6 +83,7 @@ export const eventsController = new Hono().get("/", (c) => {
|
|||||||
logger.info("Client disconnected from SSE endpoint");
|
logger.info("Client disconnected from SSE endpoint");
|
||||||
keepAlive = false;
|
keepAlive = false;
|
||||||
serverEvents.off("backup:started", onBackupStarted);
|
serverEvents.off("backup:started", onBackupStarted);
|
||||||
|
serverEvents.off("backup:progress", onBackupProgress);
|
||||||
serverEvents.off("backup:completed", onBackupCompleted);
|
serverEvents.off("backup:completed", onBackupCompleted);
|
||||||
serverEvents.off("volume:mounted", onVolumeMounted);
|
serverEvents.off("volume:mounted", onVolumeMounted);
|
||||||
serverEvents.off("volume:unmounted", onVolumeUnmounted);
|
serverEvents.off("volume:unmounted", onVolumeUnmounted);
|
||||||
|
|||||||
@@ -1,9 +1,57 @@
|
|||||||
import { Hono } from "hono";
|
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 { 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) => {
|
export const systemController = new Hono()
|
||||||
const info = await systemService.getSystemInfo();
|
.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> = {};
|
let statfs: Partial<StatFs> = {};
|
||||||
if (volume.status === "mounted") {
|
if (volume.status === "mounted") {
|
||||||
statfs = await withTimeout(getStatFs(getVolumePath(volume)), OPERATION_TIMEOUT, "getStatFs")
|
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
|
||||||
.catch((error) => {
|
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
||||||
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
return {};
|
||||||
return {};
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { volume, statfs };
|
return { volume, statfs };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { throttle } from "es-toolkit";
|
||||||
import type { RepositoryConfig } from "@ironmount/schemas/restic";
|
import type { RepositoryConfig } from "@ironmount/schemas/restic";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
@@ -8,6 +9,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants";
|
|||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { cryptoUtils } from "./crypto";
|
import { cryptoUtils } from "./crypto";
|
||||||
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
||||||
|
import { safeSpawn } from "./spawn";
|
||||||
|
|
||||||
const backupOutputSchema = type({
|
const backupOutputSchema = type({
|
||||||
message_type: "'summary'",
|
message_type: "'summary'",
|
||||||
@@ -111,10 +113,29 @@ const init = async (config: RepositoryConfig) => {
|
|||||||
return { success: true, error: null };
|
return { success: true, error: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backupProgressSchema = type({
|
||||||
|
message_type: "'status'",
|
||||||
|
seconds_elapsed: "number",
|
||||||
|
percent_done: "number",
|
||||||
|
total_files: "number",
|
||||||
|
files_done: "number",
|
||||||
|
total_bytes: "number",
|
||||||
|
bytes_done: "number",
|
||||||
|
current_files: "string[]",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BackupProgress = typeof backupProgressSchema.infer;
|
||||||
|
|
||||||
const backup = async (
|
const backup = async (
|
||||||
config: RepositoryConfig,
|
config: RepositoryConfig,
|
||||||
source: string,
|
source: string,
|
||||||
options?: { exclude?: string[]; include?: string[]; tags?: string[] },
|
options?: {
|
||||||
|
exclude?: string[];
|
||||||
|
include?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (progress: BackupProgress) => void;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
@@ -148,28 +169,55 @@ const backup = async (
|
|||||||
|
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
const logData = throttle((data: string) => {
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
logger.info(data.trim());
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
if (includeFile) {
|
const streamProgress = throttle((data: string) => {
|
||||||
await fs.unlink(includeFile).catch(() => {});
|
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) {
|
let stdout = "";
|
||||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
|
||||||
throw new Error(`Restic backup failed: ${res.stderr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// res is a succession of JSON objects, we need to parse the last one which contains the summary
|
await safeSpawn({
|
||||||
const stdout = res.text();
|
command: "restic",
|
||||||
const outputLines = stdout.trim().split("\n");
|
args,
|
||||||
const lastLine = outputLines[outputLines.length - 1];
|
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 resSummary = JSON.parse(lastLine ?? "{}");
|
||||||
|
|
||||||
const result = backupOutputSchema(resSummary);
|
const result = backupOutputSchema(resSummary);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
logger.error(`Restic backup output validation failed: ${result}`);
|
logger.error(`Restic backup output validation failed: ${result}`);
|
||||||
|
|
||||||
throw new 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("--prune");
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
@@ -429,7 +476,7 @@ const unlock = async (config: RepositoryConfig) => {
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic unlock --repo ${repoUrl} --json`.env(env).nothrow();
|
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||||
@@ -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}`);
|
logger.info(`Restic check completed for repository: ${repoUrl}`);
|
||||||
return {
|
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-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"dockerode": "^4.0.8",
|
"dockerode": "^4.0.8",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"drizzle-orm": "^0.44.6",
|
"drizzle-orm": "^0.44.6",
|
||||||
|
"es-toolkit": "^1.41.0",
|
||||||
"hono": "^4.9.2",
|
"hono": "^4.9.2",
|
||||||
"hono-openapi": "^1.1.0",
|
"hono-openapi": "^1.1.0",
|
||||||
"http-errors-enhanced": "^3.0.2",
|
"http-errors-enhanced": "^3.0.2",
|
||||||
@@ -376,6 +378,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||||
|
|
||||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||||
@@ -812,7 +816,7 @@
|
|||||||
|
|
||||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
"es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
|
"es-toolkit": ["es-toolkit@1.41.0", "", {}, "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
||||||
|
|
||||||
@@ -1484,6 +1488,10 @@
|
|||||||
|
|
||||||
"@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
"@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
|
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
|
||||||
|
|
||||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||||
@@ -1594,6 +1602,8 @@
|
|||||||
|
|
||||||
"protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
|
"protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
|
||||||
|
|
||||||
|
"recharts/es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
|
||||||
|
|
||||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|
||||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
@@ -1660,6 +1670,8 @@
|
|||||||
|
|
||||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||||
|
|
||||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user