mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
16 Commits
b0e09c61e2
...
16b8be2cd9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16b8be2cd9 | ||
|
|
7ff38f0128 | ||
|
|
33e6f3773b | ||
|
|
a91dede086 | ||
|
|
9b46737852 | ||
|
|
999850dab8 | ||
|
|
dbd9ae2241 | ||
|
|
0287bca4bb | ||
|
|
9a9991eb9b | ||
|
|
03b898f84c | ||
|
|
6fbb11fefe | ||
|
|
3bf3b22b96 | ||
|
|
58708cf35d | ||
|
|
1d4e7100ab | ||
|
|
0dfe000148 | ||
|
|
7d9d3d5d3d |
10
README.md
10
README.md
@@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.13
|
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -78,7 +78,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.13
|
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -146,7 +146,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.13
|
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -205,7 +205,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ze
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.13
|
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -236,7 +236,7 @@ In order to enable this feature, you need to run Zerobyte with several items sha
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.13
|
image: ghcr.io/nicotsx/zerobyte:v0.15
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
import { client } from '../client.gen';
|
||||||
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user
|
* Register a new user
|
||||||
@@ -755,6 +755,59 @@ export const updateScheduleNotificationsMutation = (options?: Partial<Options<Up
|
|||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getScheduleMirrorsQueryKey = (options: Options<GetScheduleMirrorsData>) => createQueryKey("getScheduleMirrors", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mirror repository assignments for a backup schedule
|
||||||
|
*/
|
||||||
|
export const getScheduleMirrorsOptions = (options: Options<GetScheduleMirrorsData>) => queryOptions<GetScheduleMirrorsResponse, DefaultError, GetScheduleMirrorsResponse, ReturnType<typeof getScheduleMirrorsQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getScheduleMirrors({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getScheduleMirrorsQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update mirror repository assignments for a backup schedule
|
||||||
|
*/
|
||||||
|
export const updateScheduleMirrorsMutation = (options?: Partial<Options<UpdateScheduleMirrorsData>>): UseMutationOptions<UpdateScheduleMirrorsResponse, DefaultError, Options<UpdateScheduleMirrorsData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<UpdateScheduleMirrorsResponse, DefaultError, Options<UpdateScheduleMirrorsData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await updateScheduleMirrors({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey("getMirrorCompatibility", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
|
||||||
|
*/
|
||||||
|
export const getMirrorCompatibilityOptions = (options: Options<GetMirrorCompatibilityData>) => queryOptions<GetMirrorCompatibilityResponse, DefaultError, GetMirrorCompatibilityResponse, ReturnType<typeof getMirrorCompatibilityQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getMirrorCompatibility({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getMirrorCompatibilityQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey("listNotificationDestinations", options);
|
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey("listNotificationDestinations", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
@@ -476,6 +476,40 @@ export const updateScheduleNotifications = <ThrowOnError extends boolean = false
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mirror repository assignments for a backup schedule
|
||||||
|
*/
|
||||||
|
export const getScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/backups/{scheduleId}/mirrors',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update mirror repository assignments for a backup schedule
|
||||||
|
*/
|
||||||
|
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/backups/{scheduleId}/mirrors',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
|
||||||
|
*/
|
||||||
|
export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/backups/{scheduleId}/mirrors/compatibility',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all notification destinations
|
* List all notification destinations
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -709,7 +709,7 @@ export type ListRepositoriesResponses = {
|
|||||||
* List of repositories
|
* List of repositories
|
||||||
*/
|
*/
|
||||||
200: Array<{
|
200: Array<{
|
||||||
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
|
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||||
config: {
|
config: {
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
backend: 'r2';
|
backend: 'r2';
|
||||||
@@ -849,7 +849,7 @@ export type CreateRepositoryData = {
|
|||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
};
|
};
|
||||||
name: string;
|
name: string;
|
||||||
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
compressionMode?: 'auto' | 'max' | 'off';
|
||||||
};
|
};
|
||||||
path?: never;
|
path?: never;
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -924,7 +924,7 @@ export type GetRepositoryResponses = {
|
|||||||
* Repository details
|
* Repository details
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
|
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||||
config: {
|
config: {
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
backend: 'r2';
|
backend: 'r2';
|
||||||
@@ -1002,7 +1002,7 @@ export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryRe
|
|||||||
|
|
||||||
export type UpdateRepositoryData = {
|
export type UpdateRepositoryData = {
|
||||||
body?: {
|
body?: {
|
||||||
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
compressionMode?: 'auto' | 'max' | 'off';
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
@@ -1028,7 +1028,7 @@ export type UpdateRepositoryResponses = {
|
|||||||
* Repository updated successfully
|
* Repository updated successfully
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
|
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||||
config: {
|
config: {
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
backend: 'r2';
|
backend: 'r2';
|
||||||
@@ -1124,6 +1124,7 @@ export type ListSnapshotsResponses = {
|
|||||||
paths: Array<string>;
|
paths: Array<string>;
|
||||||
short_id: string;
|
short_id: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
tags: Array<string>;
|
||||||
time: number;
|
time: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -1170,6 +1171,7 @@ export type GetSnapshotDetailsResponses = {
|
|||||||
paths: Array<string>;
|
paths: Array<string>;
|
||||||
short_id: string;
|
short_id: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
tags: Array<string>;
|
||||||
time: number;
|
time: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1224,6 +1226,8 @@ export type RestoreSnapshotData = {
|
|||||||
exclude?: Array<string>;
|
exclude?: Array<string>;
|
||||||
excludeXattr?: Array<string>;
|
excludeXattr?: Array<string>;
|
||||||
include?: Array<string>;
|
include?: Array<string>;
|
||||||
|
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
|
||||||
|
targetPath?: string;
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1295,7 +1299,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
|
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
|
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||||
config: {
|
config: {
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
backend: 'r2';
|
backend: 'r2';
|
||||||
@@ -1528,7 +1532,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
|
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
|
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||||
config: {
|
config: {
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
backend: 'r2';
|
backend: 'r2';
|
||||||
@@ -1742,7 +1746,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
|
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
|
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||||
config: {
|
config: {
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
backend: 'r2';
|
backend: 'r2';
|
||||||
@@ -1963,6 +1967,10 @@ export type GetScheduleNotificationsResponses = {
|
|||||||
type: 'pushover';
|
type: 'pushover';
|
||||||
userKey: string;
|
userKey: string;
|
||||||
devices?: string;
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
type: 'telegram';
|
||||||
} | {
|
} | {
|
||||||
from: string;
|
from: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -2005,7 +2013,7 @@ export type GetScheduleNotificationsResponses = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
destinationId: number;
|
destinationId: number;
|
||||||
@@ -2047,6 +2055,10 @@ export type UpdateScheduleNotificationsResponses = {
|
|||||||
type: 'pushover';
|
type: 'pushover';
|
||||||
userKey: string;
|
userKey: string;
|
||||||
devices?: string;
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
type: 'telegram';
|
||||||
} | {
|
} | {
|
||||||
from: string;
|
from: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -2089,7 +2101,7 @@ export type UpdateScheduleNotificationsResponses = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
destinationId: number;
|
destinationId: number;
|
||||||
@@ -2102,6 +2114,231 @@ export type UpdateScheduleNotificationsResponses = {
|
|||||||
|
|
||||||
export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof UpdateScheduleNotificationsResponses];
|
export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof UpdateScheduleNotificationsResponses];
|
||||||
|
|
||||||
|
export type GetScheduleMirrorsData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
scheduleId: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/backups/{scheduleId}/mirrors';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetScheduleMirrorsResponses = {
|
||||||
|
/**
|
||||||
|
* List of mirror repository assignments for the schedule
|
||||||
|
*/
|
||||||
|
200: Array<{
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
lastCopyAt: number | null;
|
||||||
|
lastCopyError: string | null;
|
||||||
|
lastCopyStatus: 'error' | 'success' | null;
|
||||||
|
repository: {
|
||||||
|
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||||
|
config: {
|
||||||
|
accessKeyId: string;
|
||||||
|
backend: 'r2';
|
||||||
|
bucket: string;
|
||||||
|
endpoint: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
accessKeyId: string;
|
||||||
|
backend: 's3';
|
||||||
|
bucket: string;
|
||||||
|
endpoint: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
accountKey: string;
|
||||||
|
accountName: string;
|
||||||
|
backend: 'azure';
|
||||||
|
container: string;
|
||||||
|
customPassword?: string;
|
||||||
|
endpointSuffix?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'gcs';
|
||||||
|
bucket: string;
|
||||||
|
credentialsJson: string;
|
||||||
|
projectId: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'local';
|
||||||
|
name: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
path?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'rclone';
|
||||||
|
path: string;
|
||||||
|
remote: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'rest';
|
||||||
|
url: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
password?: string;
|
||||||
|
path?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'sftp';
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
privateKey: string;
|
||||||
|
user: string;
|
||||||
|
port?: number;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
id: string;
|
||||||
|
lastChecked: number | null;
|
||||||
|
lastError: string | null;
|
||||||
|
name: string;
|
||||||
|
shortId: string;
|
||||||
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
repositoryId: string;
|
||||||
|
scheduleId: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetScheduleMirrorsResponse = GetScheduleMirrorsResponses[keyof GetScheduleMirrorsResponses];
|
||||||
|
|
||||||
|
export type UpdateScheduleMirrorsData = {
|
||||||
|
body?: {
|
||||||
|
mirrors: Array<{
|
||||||
|
enabled: boolean;
|
||||||
|
repositoryId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
scheduleId: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/backups/{scheduleId}/mirrors';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateScheduleMirrorsResponses = {
|
||||||
|
/**
|
||||||
|
* Mirror assignments updated successfully
|
||||||
|
*/
|
||||||
|
200: Array<{
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
lastCopyAt: number | null;
|
||||||
|
lastCopyError: string | null;
|
||||||
|
lastCopyStatus: 'error' | 'success' | null;
|
||||||
|
repository: {
|
||||||
|
compressionMode: 'auto' | 'max' | 'off' | null;
|
||||||
|
config: {
|
||||||
|
accessKeyId: string;
|
||||||
|
backend: 'r2';
|
||||||
|
bucket: string;
|
||||||
|
endpoint: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
accessKeyId: string;
|
||||||
|
backend: 's3';
|
||||||
|
bucket: string;
|
||||||
|
endpoint: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
accountKey: string;
|
||||||
|
accountName: string;
|
||||||
|
backend: 'azure';
|
||||||
|
container: string;
|
||||||
|
customPassword?: string;
|
||||||
|
endpointSuffix?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'gcs';
|
||||||
|
bucket: string;
|
||||||
|
credentialsJson: string;
|
||||||
|
projectId: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'local';
|
||||||
|
name: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
path?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'rclone';
|
||||||
|
path: string;
|
||||||
|
remote: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'rest';
|
||||||
|
url: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
password?: string;
|
||||||
|
path?: string;
|
||||||
|
username?: string;
|
||||||
|
} | {
|
||||||
|
backend: 'sftp';
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
privateKey: string;
|
||||||
|
user: string;
|
||||||
|
port?: number;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
id: string;
|
||||||
|
lastChecked: number | null;
|
||||||
|
lastError: string | null;
|
||||||
|
name: string;
|
||||||
|
shortId: string;
|
||||||
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
repositoryId: string;
|
||||||
|
scheduleId: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses];
|
||||||
|
|
||||||
|
export type GetMirrorCompatibilityData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
scheduleId: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/backups/{scheduleId}/mirrors/compatibility';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetMirrorCompatibilityResponses = {
|
||||||
|
/**
|
||||||
|
* List of repositories with their mirror compatibility status
|
||||||
|
*/
|
||||||
|
200: Array<{
|
||||||
|
compatible: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
repositoryId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses];
|
||||||
|
|
||||||
export type ListNotificationDestinationsData = {
|
export type ListNotificationDestinationsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -2120,6 +2357,10 @@ export type ListNotificationDestinationsResponses = {
|
|||||||
type: 'pushover';
|
type: 'pushover';
|
||||||
userKey: string;
|
userKey: string;
|
||||||
devices?: string;
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
type: 'telegram';
|
||||||
} | {
|
} | {
|
||||||
from: string;
|
from: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -2162,7 +2403,7 @@ export type ListNotificationDestinationsResponses = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -2177,6 +2418,10 @@ export type CreateNotificationDestinationData = {
|
|||||||
type: 'pushover';
|
type: 'pushover';
|
||||||
userKey: string;
|
userKey: string;
|
||||||
devices?: string;
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
type: 'telegram';
|
||||||
} | {
|
} | {
|
||||||
from: string;
|
from: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -2233,6 +2478,10 @@ export type CreateNotificationDestinationResponses = {
|
|||||||
type: 'pushover';
|
type: 'pushover';
|
||||||
userKey: string;
|
userKey: string;
|
||||||
devices?: string;
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
type: 'telegram';
|
||||||
} | {
|
} | {
|
||||||
from: string;
|
from: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -2275,7 +2524,7 @@ export type CreateNotificationDestinationResponses = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2336,6 +2585,10 @@ export type GetNotificationDestinationResponses = {
|
|||||||
type: 'pushover';
|
type: 'pushover';
|
||||||
userKey: string;
|
userKey: string;
|
||||||
devices?: string;
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
type: 'telegram';
|
||||||
} | {
|
} | {
|
||||||
from: string;
|
from: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -2378,7 +2631,7 @@ export type GetNotificationDestinationResponses = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2393,6 +2646,10 @@ export type UpdateNotificationDestinationData = {
|
|||||||
type: 'pushover';
|
type: 'pushover';
|
||||||
userKey: string;
|
userKey: string;
|
||||||
devices?: string;
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
type: 'telegram';
|
||||||
} | {
|
} | {
|
||||||
from: string;
|
from: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -2459,6 +2716,10 @@ export type UpdateNotificationDestinationResponses = {
|
|||||||
type: 'pushover';
|
type: 'pushover';
|
||||||
userKey: string;
|
userKey: string;
|
||||||
devices?: string;
|
devices?: string;
|
||||||
|
} | {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
type: 'telegram';
|
||||||
} | {
|
} | {
|
||||||
from: string;
|
from: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -2501,7 +2762,7 @@ export type UpdateNotificationDestinationResponses = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
|
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,8 +115,6 @@ export const CreateRepositoryForm = ({
|
|||||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||||
max={32}
|
max={32}
|
||||||
min={2}
|
min={2}
|
||||||
disabled={mode === "update"}
|
|
||||||
className={mode === "update" ? "bg-gray-50" : ""}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Unique identifier for the repository.</FormDescription>
|
<FormDescription>Unique identifier for the repository.</FormDescription>
|
||||||
@@ -176,10 +174,8 @@ export const CreateRepositoryForm = ({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="off">Off</SelectItem>
|
<SelectItem value="off">Off</SelectItem>
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
<SelectItem value="auto">Auto (fast)</SelectItem>
|
||||||
<SelectItem value="fastest">Fastest</SelectItem>
|
<SelectItem value="max">Max (slower, better compression)</SelectItem>
|
||||||
<SelectItem value="better">Better</SelectItem>
|
|
||||||
<SelectItem value="max">Max</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
|
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||||
max={32}
|
max={32}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={mode === "update"}
|
|
||||||
className={mode === "update" ? "bg-gray-50" : ""}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Unique identifier for the volume.</FormDescription>
|
<FormDescription>Unique identifier for the volume.</FormDescription>
|
||||||
|
|||||||
39
app/client/components/path-selector.tsx
Normal file
39
app/client/components/path-selector.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { DirectoryBrowser } from "./directory-browser";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
onChange: (path: string) => void;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PathSelector = ({ value, onChange }: Props) => {
|
||||||
|
const [showBrowser, setShowBrowser] = useState(false);
|
||||||
|
|
||||||
|
if (showBrowser) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<DirectoryBrowser
|
||||||
|
onSelectPath={(path) => {
|
||||||
|
onChange(path);
|
||||||
|
setShowBrowser(false);
|
||||||
|
}}
|
||||||
|
selectedPath={value}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">{value}</div>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowBrowser(true)} size="sm">
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
325
app/client/components/restore-form.tsx
Normal file
325
app/client/components/restore-form.tsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
|
import { Input } from "~/client/components/ui/input";
|
||||||
|
import { Label } from "~/client/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
|
import { PathSelector } from "~/client/components/path-selector";
|
||||||
|
import { FileTree } from "~/client/components/file-tree";
|
||||||
|
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
|
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
||||||
|
import type { Snapshot } from "~/client/lib/types";
|
||||||
|
|
||||||
|
type RestoreLocation = "original" | "custom";
|
||||||
|
|
||||||
|
interface RestoreFormProps {
|
||||||
|
snapshot: Snapshot;
|
||||||
|
repositoryName: string;
|
||||||
|
snapshotId: string;
|
||||||
|
returnPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
||||||
|
|
||||||
|
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
||||||
|
const [customTargetPath, setCustomTargetPath] = useState("");
|
||||||
|
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [excludeXattr, setExcludeXattr] = useState("");
|
||||||
|
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||||
|
|
||||||
|
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const { data: filesData, isLoading: filesLoading } = useQuery({
|
||||||
|
...listSnapshotFilesOptions({
|
||||||
|
path: { name: repositoryName, snapshotId },
|
||||||
|
query: { path: volumeBasePath },
|
||||||
|
}),
|
||||||
|
enabled: !!repositoryName && !!snapshotId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripBasePath = useCallback(
|
||||||
|
(path: string): string => {
|
||||||
|
if (!volumeBasePath) return path;
|
||||||
|
if (path === volumeBasePath) return "/";
|
||||||
|
if (path.startsWith(`${volumeBasePath}/`)) {
|
||||||
|
const stripped = path.slice(volumeBasePath.length);
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
[volumeBasePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addBasePath = useCallback(
|
||||||
|
(displayPath: string): string => {
|
||||||
|
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
||||||
|
|
||||||
|
if (!vbp) return displayPath;
|
||||||
|
if (displayPath === "/") return vbp;
|
||||||
|
return `${vbp}${displayPath}`;
|
||||||
|
},
|
||||||
|
[volumeBasePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileBrowser = useFileBrowser({
|
||||||
|
initialData: filesData,
|
||||||
|
isLoading: filesLoading,
|
||||||
|
fetchFolder: async (path) => {
|
||||||
|
return await queryClient.ensureQueryData(
|
||||||
|
listSnapshotFilesOptions({
|
||||||
|
path: { name: repositoryName, snapshotId },
|
||||||
|
query: { path },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
prefetchFolder: (path) => {
|
||||||
|
queryClient.prefetchQuery(
|
||||||
|
listSnapshotFilesOptions({
|
||||||
|
path: { name: repositoryName, snapshotId },
|
||||||
|
query: { path },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pathTransform: {
|
||||||
|
strip: stripBasePath,
|
||||||
|
add: addBasePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
||||||
|
...restoreSnapshotMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success("Restore completed", {
|
||||||
|
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
|
||||||
|
});
|
||||||
|
navigate(returnPath);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRestore = useCallback(() => {
|
||||||
|
if (!repositoryName || !snapshotId) return;
|
||||||
|
|
||||||
|
const excludeXattrArray = excludeXattr
|
||||||
|
?.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const isCustomLocation = restoreLocation === "custom";
|
||||||
|
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
|
||||||
|
|
||||||
|
const pathsArray = Array.from(selectedPaths);
|
||||||
|
const includePaths = pathsArray.map((path) => addBasePath(path));
|
||||||
|
|
||||||
|
restoreSnapshot({
|
||||||
|
path: { name: repositoryName },
|
||||||
|
body: {
|
||||||
|
snapshotId,
|
||||||
|
include: includePaths.length > 0 ? includePaths : undefined,
|
||||||
|
delete: deleteExtraFiles,
|
||||||
|
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
||||||
|
targetPath,
|
||||||
|
overwrite: overwriteMode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
repositoryName,
|
||||||
|
snapshotId,
|
||||||
|
excludeXattr,
|
||||||
|
restoreLocation,
|
||||||
|
customTargetPath,
|
||||||
|
selectedPaths,
|
||||||
|
addBasePath,
|
||||||
|
deleteExtraFiles,
|
||||||
|
overwriteMode,
|
||||||
|
restoreSnapshot,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const canRestore = restoreLocation === "original" || customTargetPath.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{repositoryName} / {snapshotId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => navigate(returnPath)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>
|
||||||
|
{isRestoring
|
||||||
|
? "Restoring..."
|
||||||
|
: selectedPaths.size > 0
|
||||||
|
? `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`
|
||||||
|
: "Restore All"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Restore Location</CardTitle>
|
||||||
|
<CardDescription>Choose where to restore the files</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={restoreLocation === "original" ? "secondary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="flex justify-start gap-2"
|
||||||
|
onClick={() => setRestoreLocation("original")}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} className="mr-1" />
|
||||||
|
Original location
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={restoreLocation === "custom" ? "secondary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="justify-start gap-2"
|
||||||
|
onClick={() => setRestoreLocation("custom")}
|
||||||
|
>
|
||||||
|
<FolderOpen size={16} className="mr-1" />
|
||||||
|
Custom location
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{restoreLocation === "custom" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<PathSelector value={customTargetPath || "/"} onChange={setCustomTargetPath} />
|
||||||
|
<p className="text-xs text-muted-foreground">Files will be restored directly to this path</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Overwrite Mode</CardTitle>
|
||||||
|
<CardDescription>How to handle existing files</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Select value={overwriteMode} onValueChange={(value) => setOverwriteMode(value as OverwriteMode)}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select overwrite behavior" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={OVERWRITE_MODES.always}>Always overwrite</SelectItem>
|
||||||
|
<SelectItem value={OVERWRITE_MODES.ifChanged}>Only if content changed</SelectItem>
|
||||||
|
<SelectItem value={OVERWRITE_MODES.ifNewer}>Only if snapshot is newer</SelectItem>
|
||||||
|
<SelectItem value={OVERWRITE_MODES.never}>Never overwrite</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{overwriteMode === OVERWRITE_MODES.always &&
|
||||||
|
"Existing files will always be replaced with the snapshot version."}
|
||||||
|
{overwriteMode === OVERWRITE_MODES.ifChanged &&
|
||||||
|
"Files are only replaced if their content differs from the snapshot."}
|
||||||
|
{overwriteMode === OVERWRITE_MODES.ifNewer &&
|
||||||
|
"Files are only replaced if the snapshot version has a newer modification time."}
|
||||||
|
{overwriteMode === OVERWRITE_MODES.never &&
|
||||||
|
"Existing files will never be replaced, only missing files are restored."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="cursor-pointer" onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Advanced options</CardTitle>
|
||||||
|
<ChevronDown size={16} className={`transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{showAdvanced && (
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="exclude-xattr" className="text-sm">
|
||||||
|
Exclude extended attributes
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="exclude-xattr"
|
||||||
|
placeholder="com.apple.metadata,user.*,nfs4.*"
|
||||||
|
value={excludeXattr}
|
||||||
|
onChange={(e) => setExcludeXattr(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Exclude specific extended attributes during restore (comma-separated)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="delete-extra"
|
||||||
|
checked={deleteExtraFiles}
|
||||||
|
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
|
||||||
|
Delete files not present in the snapshot
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Card className="lg:col-span-2 flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Select Files to Restore</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{selectedPaths.size > 0
|
||||||
|
? `${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"} selected`
|
||||||
|
: "Select specific files or folders, or leave empty to restore everything"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||||
|
{fileBrowser.isLoading && (
|
||||||
|
<div className="flex items-center justify-center flex-1">
|
||||||
|
<p className="text-muted-foreground">Loading files...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fileBrowser.isEmpty && (
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
||||||
|
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||||
|
<p className="text-muted-foreground">No files in this snapshot</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
||||||
|
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
||||||
|
<FileTree
|
||||||
|
files={fileBrowser.fileArray}
|
||||||
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
|
className="px-2 py-2"
|
||||||
|
withCheckboxes={true}
|
||||||
|
selectedPaths={selectedPaths}
|
||||||
|
onSelectionChange={setSelectedPaths}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
@@ -18,18 +18,17 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
import type { ListSnapshotsResponse } from "../api-client";
|
|
||||||
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import type { BackupSchedule, Snapshot } from "../lib/types";
|
||||||
type Snapshot = ListSnapshotsResponse[number];
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
snapshots: Snapshot[];
|
snapshots: Snapshot[];
|
||||||
|
backups: BackupSchedule[];
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
@@ -76,6 +75,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
|||||||
<TableHeader className="bg-card-header">
|
<TableHeader className="bg-card-header">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="uppercase">Snapshot ID</TableHead>
|
<TableHead className="uppercase">Snapshot ID</TableHead>
|
||||||
|
<TableHead className="uppercase">Schedule</TableHead>
|
||||||
<TableHead className="uppercase">Date & Time</TableHead>
|
<TableHead className="uppercase">Date & Time</TableHead>
|
||||||
<TableHead className="uppercase">Size</TableHead>
|
<TableHead className="uppercase">Size</TableHead>
|
||||||
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||||
@@ -84,71 +84,91 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{snapshots.map((snapshot) => (
|
{snapshots.map((snapshot) => {
|
||||||
<TableRow
|
const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
|
||||||
key={snapshot.short_id}
|
const backup = backups.find((b) => backupIds.includes(b.id));
|
||||||
className="hover:bg-accent/50 cursor-pointer"
|
|
||||||
onClick={() => handleRowClick(snapshot.short_id)}
|
return (
|
||||||
>
|
<TableRow
|
||||||
<TableCell className="font-mono text-sm">
|
key={snapshot.short_id}
|
||||||
<div className="flex items-center gap-2">
|
className="hover:bg-accent/50 cursor-pointer"
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
onClick={() => handleRowClick(snapshot.short_id)}
|
||||||
<span className="text-strong-accent">{snapshot.short_id}</span>
|
>
|
||||||
</div>
|
<TableCell className="font-mono text-sm">
|
||||||
</TableCell>
|
<div className="flex items-center gap-2">
|
||||||
<TableCell>
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-strong-accent">{snapshot.short_id}</span>
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
</div>
|
||||||
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
|
</TableCell>
|
||||||
</div>
|
<TableCell>
|
||||||
</TableCell>
|
<div className="flex items-center gap-2">
|
||||||
<TableCell>
|
<Link
|
||||||
<div className="flex items-center gap-2">
|
hidden={!backup}
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
to={backup ? `/backups/${backup.id}` : "#"}
|
||||||
<span className="font-medium">
|
onClick={(e) => e.stopPropagation()}
|
||||||
<ByteSize bytes={snapshot.size} base={1024} />
|
className="hover:underline"
|
||||||
</span>
|
>
|
||||||
</div>
|
<span className="text-sm">{backup ? backup.id : "-"}</span>
|
||||||
</TableCell>
|
</Link>
|
||||||
<TableCell className="hidden md:table-cell">
|
<span hidden={!!backup} className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center justify-end gap-2">
|
-
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
</div>
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
</TableCell>
|
||||||
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
|
<TableCell>
|
||||||
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
<span className="font-medium">
|
||||||
<TooltipContent side="top" className="max-w-md">
|
<ByteSize bytes={snapshot.size} base={1024} />
|
||||||
<div className="flex flex-col gap-1">
|
</span>
|
||||||
{snapshot.paths.map((path) => (
|
</div>
|
||||||
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
|
</TableCell>
|
||||||
{path}
|
<TableCell className="hidden md:table-cell">
|
||||||
</div>
|
<div className="flex items-center justify-end gap-2">
|
||||||
))}
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||||
</TooltipContent>
|
</div>
|
||||||
</Tooltip>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="hidden lg:table-cell">
|
||||||
</TableCell>
|
<div className="flex items-center justify-end gap-2">
|
||||||
<TableCell className="text-right">
|
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
size="sm"
|
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
|
||||||
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
|
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
|
||||||
disabled={deleteSnapshot.isPending}
|
</span>
|
||||||
>
|
</TooltipTrigger>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<TooltipContent side="top" className="max-w-md">
|
||||||
</Button>
|
<div className="flex flex-col gap-1">
|
||||||
</TableCell>
|
{snapshot.paths.map((path) => (
|
||||||
</TableRow>
|
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
|
||||||
))}
|
{path}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
|
||||||
|
disabled={deleteSnapshot.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ type ServerEventType =
|
|||||||
| "backup:completed"
|
| "backup:completed"
|
||||||
| "volume:mounted"
|
| "volume:mounted"
|
||||||
| "volume:unmounted"
|
| "volume:unmounted"
|
||||||
| "volume:updated";
|
| "volume:updated"
|
||||||
|
| "mirror:started"
|
||||||
|
| "mirror:completed";
|
||||||
|
|
||||||
export interface BackupEvent {
|
export interface BackupEvent {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
@@ -35,6 +37,14 @@ export interface VolumeEvent {
|
|||||||
volumeName: string;
|
volumeName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MirrorEvent {
|
||||||
|
scheduleId: number;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryName: string;
|
||||||
|
status?: "success" | "error";
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
type EventHandler = (data: unknown) => void;
|
type EventHandler = (data: unknown) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +135,27 @@ export function useServerEvents() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("mirror:started", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as MirrorEvent;
|
||||||
|
console.log("[SSE] Mirror copy started:", data);
|
||||||
|
|
||||||
|
handlersRef.current.get("mirror:started")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("mirror:completed", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as MirrorEvent;
|
||||||
|
console.log("[SSE] Mirror copy completed:", data);
|
||||||
|
|
||||||
|
// Invalidate queries to refresh mirror status in the UI
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
|
||||||
|
handlersRef.current.get("mirror:completed")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error("[SSE] Connection error:", error);
|
console.error("[SSE] Connection error:", error);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Copy, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { Switch } from "~/client/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
|
import { Badge } from "~/client/components/ui/badge";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
getScheduleMirrorsOptions,
|
||||||
|
getMirrorCompatibilityOptions,
|
||||||
|
updateScheduleMirrorsMutation,
|
||||||
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import type { Repository } from "~/client/lib/types";
|
||||||
|
import { RepositoryIcon } from "~/client/components/repository-icon";
|
||||||
|
import { StatusDot } from "~/client/components/status-dot";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
scheduleId: number;
|
||||||
|
primaryRepositoryId: string;
|
||||||
|
repositories: Repository[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MirrorAssignment = {
|
||||||
|
repositoryId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
lastCopyAt: number | null;
|
||||||
|
lastCopyStatus: "success" | "error" | null;
|
||||||
|
lastCopyError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, repositories }: Props) => {
|
||||||
|
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(new Map());
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
|
|
||||||
|
const { data: currentMirrors } = useQuery({
|
||||||
|
...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: compatibility } = useQuery({
|
||||||
|
...getMirrorCompatibilityOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMirrors = useMutation({
|
||||||
|
...updateScheduleMirrorsMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Mirror settings saved successfully");
|
||||||
|
setHasChanges(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to save mirror settings", {
|
||||||
|
description: parseError(error)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const compatibilityMap = useMemo(() => {
|
||||||
|
const map = new Map<string, { compatible: boolean; reason: string | null }>();
|
||||||
|
if (compatibility) {
|
||||||
|
for (const item of compatibility) {
|
||||||
|
map.set(item.repositoryId, { compatible: item.compatible, reason: item.reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [compatibility]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentMirrors) {
|
||||||
|
const map = new Map<string, MirrorAssignment>();
|
||||||
|
for (const mirror of currentMirrors) {
|
||||||
|
map.set(mirror.repositoryId, {
|
||||||
|
repositoryId: mirror.repositoryId,
|
||||||
|
enabled: mirror.enabled,
|
||||||
|
lastCopyAt: mirror.lastCopyAt,
|
||||||
|
lastCopyStatus: mirror.lastCopyStatus,
|
||||||
|
lastCopyError: mirror.lastCopyError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setAssignments(map);
|
||||||
|
}
|
||||||
|
}, [currentMirrors]);
|
||||||
|
|
||||||
|
const addRepository = (repositoryId: string) => {
|
||||||
|
const newAssignments = new Map(assignments);
|
||||||
|
newAssignments.set(repositoryId, {
|
||||||
|
repositoryId,
|
||||||
|
enabled: true,
|
||||||
|
lastCopyAt: null,
|
||||||
|
lastCopyStatus: null,
|
||||||
|
lastCopyError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setAssignments(newAssignments);
|
||||||
|
setHasChanges(true);
|
||||||
|
setIsAddingNew(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRepository = (repositoryId: string) => {
|
||||||
|
const newAssignments = new Map(assignments);
|
||||||
|
newAssignments.delete(repositoryId);
|
||||||
|
setAssignments(newAssignments);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEnabled = (repositoryId: string) => {
|
||||||
|
const assignment = assignments.get(repositoryId);
|
||||||
|
if (!assignment) return;
|
||||||
|
|
||||||
|
const newAssignments = new Map(assignments);
|
||||||
|
newAssignments.set(repositoryId, {
|
||||||
|
...assignment,
|
||||||
|
enabled: !assignment.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
setAssignments(newAssignments);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const mirrorsList = Array.from(assignments.values()).map((a) => ({
|
||||||
|
repositoryId: a.repositoryId,
|
||||||
|
enabled: a.enabled,
|
||||||
|
}));
|
||||||
|
updateMirrors.mutate({
|
||||||
|
path: { scheduleId: scheduleId.toString() },
|
||||||
|
body: {
|
||||||
|
mirrors: mirrorsList,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (currentMirrors) {
|
||||||
|
const map = new Map<string, MirrorAssignment>();
|
||||||
|
for (const mirror of currentMirrors) {
|
||||||
|
map.set(mirror.repositoryId, {
|
||||||
|
repositoryId: mirror.repositoryId,
|
||||||
|
enabled: mirror.enabled,
|
||||||
|
lastCopyAt: mirror.lastCopyAt,
|
||||||
|
lastCopyStatus: mirror.lastCopyStatus,
|
||||||
|
lastCopyError: mirror.lastCopyError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAssignments(map);
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRepositoryById = (id: string) => {
|
||||||
|
return repositories?.find((r) => r.id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectableRepositories =
|
||||||
|
repositories?.filter((r) => {
|
||||||
|
if (r.id === primaryRepositoryId) return false;
|
||||||
|
if (assignments.has(r.id)) return false;
|
||||||
|
return true;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const hasAvailableRepositories = selectableRepositories.some((r) => {
|
||||||
|
const compat = compatibilityMap.get(r.id);
|
||||||
|
return compat?.compatible !== false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignedRepositories = Array.from(assignments.keys())
|
||||||
|
.map((id) => getRepositoryById(id))
|
||||||
|
.filter((r) => r !== undefined);
|
||||||
|
|
||||||
|
const getStatusVariant = (status: "success" | "error" | null): "success" | "error" | "neutral" => {
|
||||||
|
if (status === "success") return "success";
|
||||||
|
if (status === "error") return "error";
|
||||||
|
return "neutral";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (assignment: MirrorAssignment): string => {
|
||||||
|
if (assignment.lastCopyStatus === "error" && assignment.lastCopyError) {
|
||||||
|
return assignment.lastCopyError;
|
||||||
|
}
|
||||||
|
if (assignment.lastCopyStatus === "success") {
|
||||||
|
return "Last copy successful";
|
||||||
|
}
|
||||||
|
return "Never copied";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Copy className="h-5 w-5" />
|
||||||
|
Mirror Repositories
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure secondary repositories where snapshots will be automatically copied after each backup
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{!isAddingNew && selectableRepositories.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add mirror
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isAddingNew && (
|
||||||
|
<div className="mb-6 flex items-center gap-2 max-w-md">
|
||||||
|
<Select onValueChange={addRepository}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a repository to mirror to..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{selectableRepositories.map((repository) => {
|
||||||
|
const compat = compatibilityMap.get(repository.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={repository.id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<SelectItem value={repository.id} disabled={!compat?.compatible}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
|
||||||
|
<span>{repository.name}</span>
|
||||||
|
<span className="text-xs uppercase text-muted-foreground">({repository.type})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className={cn("max-w-xs", { hidden: compat?.compatible })}>
|
||||||
|
<p>{compat?.reason || "This repository is not compatible for mirroring."}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Consider creating a new backup scheduler with the desired destination instead.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!hasAvailableRepositories && selectableRepositories.length > 0 && (
|
||||||
|
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
|
||||||
|
All available repositories have conflicting backends.
|
||||||
|
<br />
|
||||||
|
<span className="text-xs">
|
||||||
|
Consider creating a new backup scheduler with the desired destination instead.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assignedRepositories.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||||
|
<Copy className="h-8 w-8 mb-2 opacity-20" />
|
||||||
|
<p className="text-sm">No mirror repositories configured for this schedule.</p>
|
||||||
|
<p className="text-xs mt-1">Click "Add mirror" to replicate backups to additional repositories.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Repository</TableHead>
|
||||||
|
<TableHead className="text-center w-[100px]">Enabled</TableHead>
|
||||||
|
<TableHead className="w-[180px]">Last Copy</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{assignedRepositories.map((repository) => {
|
||||||
|
const assignment = assignments.get(repository.id);
|
||||||
|
if (!assignment) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={repository.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/repositories/${repository.name}`}
|
||||||
|
className="hover:underline flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
|
||||||
|
<span className="font-medium">{repository.name}</span>
|
||||||
|
</Link>
|
||||||
|
<Badge variant="outline" className="text-[10px] align-middle">
|
||||||
|
{repository.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Switch
|
||||||
|
className="align-middle"
|
||||||
|
checked={assignment.enabled}
|
||||||
|
onCheckedChange={() => toggleEnabled(repository.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{assignment.lastCopyAt ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusDot
|
||||||
|
variant={getStatusVariant(assignment.lastCopyStatus)}
|
||||||
|
label={getStatusLabel(assignment)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">Never</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeRepository(repository.id)}
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="flex gap-2 justify-end mt-4 pt-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" size="sm" onClick={handleSave} loading={updateMirrors.isPending}>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,47 +1,26 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronDown, FileIcon } from "lucide-react";
|
import { FileIcon } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
import { FileTree } from "~/client/components/file-tree";
|
import { FileTree } from "~/client/components/file-tree";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button, buttonVariants } from "~/client/components/ui/button";
|
||||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
import type { Snapshot } from "~/client/lib/types";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { Input } from "~/client/components/ui/input";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "~/client/components/ui/alert-dialog";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
|
||||||
import type { Snapshot, Volume } from "~/client/lib/types";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
volume?: Volume;
|
backupId?: string;
|
||||||
onDeleteSnapshot?: (snapshotId: string) => void;
|
onDeleteSnapshot?: (snapshotId: string) => void;
|
||||||
isDeletingSnapshot?: boolean;
|
isDeletingSnapshot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
|
const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||||
|
|
||||||
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
|
||||||
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
|
||||||
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
const [excludeXattr, setExcludeXattr] = useState("");
|
|
||||||
|
|
||||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
||||||
|
|
||||||
@@ -101,45 +80,6 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
|
||||||
...restoreSnapshotMutation(),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success("Restore completed", {
|
|
||||||
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
|
|
||||||
});
|
|
||||||
setSelectedPaths(new Set());
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleRestoreClick = useCallback(() => {
|
|
||||||
setShowRestoreDialog(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConfirmRestore = useCallback(() => {
|
|
||||||
const pathsArray = Array.from(selectedPaths);
|
|
||||||
const includePaths = pathsArray.map((path) => addBasePath(path));
|
|
||||||
|
|
||||||
const excludeXattrArray = excludeXattr
|
|
||||||
?.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
restoreSnapshot({
|
|
||||||
path: { name: repositoryName },
|
|
||||||
body: {
|
|
||||||
snapshotId: snapshot.short_id,
|
|
||||||
include: includePaths,
|
|
||||||
delete: deleteExtraFiles,
|
|
||||||
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowRestoreDialog(false);
|
|
||||||
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="h-[600px] flex flex-col">
|
<Card className="h-[600px] flex flex-col">
|
||||||
@@ -150,30 +90,16 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{selectedPaths.size > 0 && (
|
<Link
|
||||||
<Tooltip>
|
to={
|
||||||
<TooltipTrigger asChild>
|
backupId
|
||||||
<span tabIndex={isReadOnly ? 0 : undefined}>
|
? `/backups/${backupId}/${snapshot.short_id}/restore`
|
||||||
<Button
|
: `/repositories/${repositoryName}/${snapshot.short_id}/restore`
|
||||||
onClick={handleRestoreClick}
|
}
|
||||||
variant="primary"
|
className={buttonVariants({ variant: "primary", size: "sm" })}
|
||||||
size="sm"
|
>
|
||||||
disabled={isRestoring || isReadOnly}
|
Restore
|
||||||
>
|
</Link>
|
||||||
{isRestoring
|
|
||||||
? "Restoring..."
|
|
||||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{isReadOnly && (
|
|
||||||
<TooltipContent className="text-center">
|
|
||||||
<p>Volume is mounted as read-only.</p>
|
|
||||||
<p>Please remount with read-only disabled to restore files.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{onDeleteSnapshot && (
|
{onDeleteSnapshot && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -211,73 +137,11 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
expandedFolders={fileBrowser.expandedFolders}
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
loadingFolders={fileBrowser.loadingFolders}
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
className="px-2 py-2"
|
className="px-2 py-2"
|
||||||
withCheckboxes={true}
|
|
||||||
selectedPaths={selectedPaths}
|
|
||||||
onSelectionChange={setSelectedPaths}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AlertDialog open={showRestoreDialog} onOpenChange={setShowRestoreDialog}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Confirm Restore</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{selectedPaths.size > 0
|
|
||||||
? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.`
|
|
||||||
: "This will restore everything from the snapshot."}{" "}
|
|
||||||
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
||||||
className="h-auto p-0 text-sm font-normal"
|
|
||||||
>
|
|
||||||
Advanced
|
|
||||||
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{showAdvanced && (
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<Label htmlFor="exclude-xattr" className="text-sm">
|
|
||||||
Exclude Extended Attributes (Optional)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="exclude-xattr"
|
|
||||||
placeholder="com.apple.metadata,user.*,nfs4.*"
|
|
||||||
value={excludeXattr}
|
|
||||||
onChange={(e) => setExcludeXattr(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Exclude specific extended attributes during restore (comma-separated)
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
|
||||||
<Checkbox
|
|
||||||
id="delete-extra"
|
|
||||||
checked={deleteExtraFiles}
|
|
||||||
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
|
|
||||||
Delete files not present in the snapshot?
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleConfirmRestore}>Confirm</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ import { ScheduleSummary } from "../components/schedule-summary";
|
|||||||
import type { Route } from "./+types/backup-details";
|
import type { Route } from "./+types/backup-details";
|
||||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||||
import { getBackupSchedule, listNotificationDestinations } from "~/client/api-client";
|
import { getBackupSchedule, listNotificationDestinations, listRepositories } from "~/client/api-client";
|
||||||
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
|
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
|
||||||
|
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
@@ -53,10 +54,11 @@ export function meta(_: Route.MetaArgs) {
|
|||||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||||
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||||
const notifs = await listNotificationDestinations();
|
const notifs = await listNotificationDestinations();
|
||||||
|
const repos = await listRepositories();
|
||||||
|
|
||||||
if (!schedule.data) return redirect("/backups");
|
if (!schedule.data) return redirect("/backups");
|
||||||
|
|
||||||
return { schedule: schedule.data, notifs: notifs.data };
|
return { schedule: schedule.data, notifs: notifs.data, repos: repos.data };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||||
@@ -70,8 +72,6 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
const { data: schedule } = useQuery({
|
const { data: schedule } = useQuery({
|
||||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||||
initialData: loaderData.schedule,
|
initialData: loaderData.schedule,
|
||||||
refetchInterval: 10000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -228,6 +228,13 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
<div className={cn({ hidden: !loaderData.notifs?.length })}>
|
<div className={cn({ hidden: !loaderData.notifs?.length })}>
|
||||||
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
|
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={cn({ hidden: !loaderData.repos?.length || loaderData.repos.length < 2 })}>
|
||||||
|
<ScheduleMirrorsConfig
|
||||||
|
scheduleId={schedule.id}
|
||||||
|
primaryRepositoryId={schedule.repositoryId}
|
||||||
|
repositories={loaderData.repos ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<SnapshotTimeline
|
<SnapshotTimeline
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
snapshots={snapshots ?? []}
|
snapshots={snapshots ?? []}
|
||||||
@@ -240,7 +247,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
key={selectedSnapshot?.short_id}
|
key={selectedSnapshot?.short_id}
|
||||||
snapshot={selectedSnapshot}
|
snapshot={selectedSnapshot}
|
||||||
repositoryName={schedule.repository.name}
|
repositoryName={schedule.repository.name}
|
||||||
volume={schedule.volume}
|
backupId={schedule.id.toString()}
|
||||||
onDeleteSnapshot={handleDeleteSnapshot}
|
onDeleteSnapshot={handleDeleteSnapshot}
|
||||||
isDeletingSnapshot={deleteSnapshot.isPending}
|
isDeletingSnapshot={deleteSnapshot.isPending}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
|||||||
const { data: schedules, isLoading } = useQuery({
|
const { data: schedules, isLoading } = useQuery({
|
||||||
...listBackupSchedulesOptions(),
|
...listBackupSchedulesOptions(),
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
54
app/client/modules/backups/routes/restore-snapshot.tsx
Normal file
54
app/client/modules/backups/routes/restore-snapshot.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { redirect } from "react-router";
|
||||||
|
import { getBackupSchedule, getSnapshotDetails } from "~/client/api-client";
|
||||||
|
import { RestoreForm } from "~/client/components/restore-form";
|
||||||
|
import type { Route } from "./+types/restore-snapshot";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: (match: Route.MetaArgs) => [
|
||||||
|
{ label: "Backups", href: "/backups" },
|
||||||
|
{ label: `Schedule #${match.params.id}`, href: `/backups/${match.params.id}` },
|
||||||
|
{ label: match.params.snapshotId },
|
||||||
|
{ label: "Restore" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Restore files from a backup snapshot.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
|
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||||
|
if (!schedule.data) return redirect("/backups");
|
||||||
|
|
||||||
|
const repositoryName = schedule.data.repository.name;
|
||||||
|
const snapshot = await getSnapshotDetails({
|
||||||
|
path: { name: repositoryName, snapshotId: params.snapshotId },
|
||||||
|
});
|
||||||
|
if (!snapshot.data) return redirect(`/backups/${params.id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: snapshot.data,
|
||||||
|
repositoryName,
|
||||||
|
snapshotId: params.snapshotId,
|
||||||
|
backupId: params.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { snapshot, repositoryName, snapshotId, backupId } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RestoreForm
|
||||||
|
snapshot={snapshot}
|
||||||
|
repositoryName={repositoryName}
|
||||||
|
snapshotId={snapshotId}
|
||||||
|
returnPath={`/backups/${backupId}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ type Props = {
|
|||||||
mode?: "create" | "update";
|
mode?: "create" | "update";
|
||||||
initialValues?: Partial<NotificationFormValues>;
|
initialValues?: Partial<NotificationFormValues>;
|
||||||
formId?: string;
|
formId?: string;
|
||||||
loading?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +69,11 @@ const defaultValuesForType = {
|
|||||||
apiToken: "",
|
apiToken: "",
|
||||||
priority: 0 as const,
|
priority: 0 as const,
|
||||||
},
|
},
|
||||||
|
telegram: {
|
||||||
|
type: "telegram" as const,
|
||||||
|
botToken: "",
|
||||||
|
chatId: "",
|
||||||
|
},
|
||||||
custom: {
|
custom: {
|
||||||
type: "custom" as const,
|
type: "custom" as const,
|
||||||
shoutrrrUrl: "",
|
shoutrrrUrl: "",
|
||||||
@@ -114,8 +118,6 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||||
max={32}
|
max={32}
|
||||||
min={2}
|
min={2}
|
||||||
disabled={mode === "update"}
|
|
||||||
className={mode === "update" ? "bg-gray-50" : ""}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Unique identifier for this notification destination.</FormDescription>
|
<FormDescription>Unique identifier for this notification destination.</FormDescription>
|
||||||
@@ -148,6 +150,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||||||
<SelectItem value="gotify">Gotify</SelectItem>
|
<SelectItem value="gotify">Gotify</SelectItem>
|
||||||
<SelectItem value="ntfy">Ntfy</SelectItem>
|
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||||
<SelectItem value="pushover">Pushover</SelectItem>
|
<SelectItem value="pushover">Pushover</SelectItem>
|
||||||
|
<SelectItem value="telegram">Telegram</SelectItem>
|
||||||
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
|
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -615,6 +618,41 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{watchedType === "telegram" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="botToken"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bot Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="password" placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Telegram bot token. Get this from BotFather when you create your bot.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="chatId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Chat ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="-1231234567890" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchedType === "custom" && (
|
{watchedType === "custom" && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -62,12 +62,7 @@ export default function CreateNotification() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<CreateNotificationForm
|
<CreateNotificationForm mode="create" formId={formId} onSubmit={handleSubmit} />
|
||||||
mode="create"
|
|
||||||
formId={formId}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
loading={createNotification.isPending}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
|
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -171,20 +171,12 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<>
|
<CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} />
|
||||||
<CreateNotificationForm
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
mode="update"
|
<Button type="submit" form={formId} loading={updateDestination.isPending}>
|
||||||
formId={formId}
|
Save Changes
|
||||||
onSubmit={handleSubmit}
|
</Button>
|
||||||
initialValues={data.config}
|
</div>
|
||||||
loading={updateDestination.isPending}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
|
||||||
<Button type="submit" form={formId} loading={updateDestination.isPending}>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...listNotificationDestinationsOptions(),
|
...listNotificationDestinationsOptions(),
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredNotifications =
|
const filteredNotifications =
|
||||||
@@ -102,6 +100,7 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||||||
<SelectItem value="gotify">Gotify</SelectItem>
|
<SelectItem value="gotify">Gotify</SelectItem>
|
||||||
<SelectItem value="ntfy">Ntfy</SelectItem>
|
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||||
<SelectItem value="pushover">Pushover</SelectItem>
|
<SelectItem value="pushover">Pushover</SelectItem>
|
||||||
|
<SelectItem value="telegram">Telegram</SelectItem>
|
||||||
<SelectItem value="custom">Custom</SelectItem>
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { RotateCcw } from "lucide-react";
|
|
||||||
import { useId, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import { Button } from "~/client/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "~/client/components/ui/dialog";
|
|
||||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
|
||||||
import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
name: string;
|
|
||||||
snapshotId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const formId = useId();
|
|
||||||
|
|
||||||
const restore = useMutation({
|
|
||||||
...restoreSnapshotMutation(),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success("Snapshot restored successfully", {
|
|
||||||
description: `${data.filesRestored} files restored, ${data.filesSkipped} files skipped`,
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Failed to restore snapshot", {
|
|
||||||
description: parseError(error)?.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: RestoreSnapshotFormValues) => {
|
|
||||||
const include = values.include
|
|
||||||
?.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const exclude = values.exclude
|
|
||||||
?.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const excludeXattr = values.excludeXattr
|
|
||||||
?.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
restore.mutate({
|
|
||||||
path: { name },
|
|
||||||
body: {
|
|
||||||
snapshotId,
|
|
||||||
include: include && include.length > 0 ? include : undefined,
|
|
||||||
exclude: exclude && exclude.length > 0 ? exclude : undefined,
|
|
||||||
excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<RotateCcw size={16} className="mr-2" />
|
|
||||||
Restore
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<ScrollArea className="max-h-[600px] p-4">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Restore Snapshot</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Restore snapshot {snapshotId.substring(0, 8)} to a local filesystem path
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<RestoreSnapshotForm className="mt-4" formId={formId} onSubmit={handleSubmit} />
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" form={formId} disabled={restore.isPending}>
|
|
||||||
{restore.isPending ? "Restoring..." : "Restore"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</ScrollArea>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
|
||||||
import { type } from "arktype";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "~/client/components/ui/form";
|
|
||||||
import { Input } from "~/client/components/ui/input";
|
|
||||||
import { Button } from "~/client/components/ui/button";
|
|
||||||
|
|
||||||
const restoreSnapshotFormSchema = type({
|
|
||||||
path: "string?",
|
|
||||||
include: "string?",
|
|
||||||
exclude: "string?",
|
|
||||||
excludeXattr: "string?",
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
formId: string;
|
|
||||||
onSubmit: (values: RestoreSnapshotFormValues) => void;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<RestoreSnapshotFormValues>({
|
|
||||||
resolver: arktypeResolver(restoreSnapshotFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
path: "",
|
|
||||||
include: "",
|
|
||||||
exclude: "",
|
|
||||||
excludeXattr: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (values: RestoreSnapshotFormValues) => {
|
|
||||||
onSubmit(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form id={formId} onSubmit={form.handleSubmit(handleSubmit)} className={className}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Path (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="/specific/path" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Restore only a specific path from the snapshot (leave empty to restore all)
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="include"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Include Patterns (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="*.txt,/documents/**" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Include only files matching these patterns (comma-separated)</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="exclude"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Exclude Patterns (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="*.log,/temp/**" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Exclude files matching these patterns (comma-separated)</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
||||||
className="h-auto p-0 text-sm font-normal"
|
|
||||||
>
|
|
||||||
Advanced
|
|
||||||
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{showAdvanced && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="excludeXattr"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Exclude Extended Attributes (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="com.apple.metadata,user.custom" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Exclude specific extended attributes during restore (comma-separated)
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -50,8 +50,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...listRepositoriesOptions(),
|
...listRepositoriesOptions(),
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredRepositories =
|
const filteredRepositories =
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
45
app/client/modules/repositories/routes/restore-snapshot.tsx
Normal file
45
app/client/modules/repositories/routes/restore-snapshot.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { redirect } from "react-router";
|
||||||
|
import { getSnapshotDetails } from "~/client/api-client";
|
||||||
|
import { RestoreForm } from "~/client/components/restore-form";
|
||||||
|
import type { Route } from "./+types/restore-snapshot";
|
||||||
|
|
||||||
|
export const handle = {
|
||||||
|
breadcrumb: (match: Route.MetaArgs) => [
|
||||||
|
{ label: "Repositories", href: "/repositories" },
|
||||||
|
{ label: match.params.name, href: `/repositories/${match.params.name}` },
|
||||||
|
{ label: match.params.snapshotId, href: `/repositories/${match.params.name}/${match.params.snapshotId}` },
|
||||||
|
{ label: "Restore" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Restore files from a backup snapshot.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
|
const snapshot = await getSnapshotDetails({
|
||||||
|
path: { name: params.name, snapshotId: params.snapshotId },
|
||||||
|
});
|
||||||
|
if (snapshot.data) return { snapshot: snapshot.data, name: params.name, snapshotId: params.snapshotId };
|
||||||
|
|
||||||
|
return redirect("/repositories");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { snapshot, name, snapshotId } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RestoreForm
|
||||||
|
snapshot={snapshot}
|
||||||
|
repositoryName={name}
|
||||||
|
snapshotId={snapshotId}
|
||||||
|
returnPath={`/repositories/${name}/${snapshotId}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { redirect, useParams } from "react-router";
|
import { redirect, useParams } from "react-router";
|
||||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
|
|
||||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||||
import { getSnapshotDetails } from "~/client/api-client";
|
import { getSnapshotDetails } from "~/client/api-client";
|
||||||
import type { Route } from "./+types/snapshot-details";
|
import type { Route } from "./+types/snapshot-details";
|
||||||
@@ -63,7 +62,6 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||||||
<h1 className="text-2xl font-bold">{name}</h1>
|
<h1 className="text-2xl font-bold">{name}</h1>
|
||||||
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
|
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
|
||||||
</div>
|
</div>
|
||||||
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
|
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
|
||||||
|
|||||||
@@ -1,63 +1,169 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
import { Card } from "~/client/components/ui/card";
|
import { Card } from "~/client/components/ui/card";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import { Input } from "~/client/components/ui/input";
|
||||||
|
import { Label } from "~/client/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import type { Repository } from "~/client/lib/types";
|
import type { Repository } from "~/client/lib/types";
|
||||||
|
import { slugify } from "~/client/lib/utils";
|
||||||
|
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import type { UpdateRepositoryResponse } from "~/client/api-client/types.gen";
|
||||||
|
import type { CompressionMode } from "~/schemas/restic";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||||
return (
|
const navigate = useNavigate();
|
||||||
<Card className="p-6">
|
const [name, setName] = useState(repository.name);
|
||||||
<div className="space-y-6">
|
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
|
||||||
<div>
|
(repository.compressionMode as CompressionMode) || "off",
|
||||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
);
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
<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 className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
const updateMutation = useMutation({
|
||||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
...updateRepositoryMutation(),
|
||||||
|
onSuccess: (data: UpdateRepositoryResponse) => {
|
||||||
|
toast.success("Repository updated successfully");
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
|
||||||
|
if (data.name !== repository.name) {
|
||||||
|
navigate(`/repositories/${data.name}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to update repository", { description: error.message, richColors: true });
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmUpdate = () => {
|
||||||
|
updateMutation.mutate({
|
||||||
|
path: { name: repository.name },
|
||||||
|
body: { name, compressionMode },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="p-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Repository Settings</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(slugify(e.target.value))}
|
||||||
|
placeholder="Repository name"
|
||||||
|
maxLength={32}
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">Unique identifier for the repository.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="compressionMode">Compression Mode</Label>
|
||||||
|
<Select value={compressionMode} onValueChange={(val) => setCompressionMode(val as CompressionMode)}>
|
||||||
|
<SelectTrigger id="compressionMode">
|
||||||
|
<SelectValue placeholder="Select compression mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="off">Off</SelectItem>
|
||||||
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
|
<SelectItem value="max">Max</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">Compression level for new data.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||||
<div className="bg-muted/50 rounded-md p-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||||
|
<p className="mt-1 text-sm">{repository.type}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">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 * 1000).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>
|
{repository.lastError && (
|
||||||
</Card>
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||||
|
</div>
|
||||||
|
<div className="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>
|
||||||
|
<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 className="flex justify-end pt-4 border-t">
|
||||||
|
<Button type="submit" disabled={!hasChanges || updateMutation.isPending} loading={updateMutation.isPending}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Update Repository</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Are you sure you want to update the repository settings?</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Database } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { listBackupSchedulesOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { SnapshotsTable } from "~/client/components/snapshots-table";
|
import { SnapshotsTable } from "~/client/components/snapshots-table";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
@@ -18,11 +18,13 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||||||
|
|
||||||
const { data, isFetching, failureReason } = useQuery({
|
const { data, isFetching, failureReason } = useQuery({
|
||||||
...listSnapshotsOptions({ path: { name: repository.name } }),
|
...listSnapshotsOptions({ path: { name: repository.name } }),
|
||||||
refetchInterval: 10000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
initialData: [],
|
initialData: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const schedules = useQuery({
|
||||||
|
...listBackupSchedulesOptions(),
|
||||||
|
});
|
||||||
|
|
||||||
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
|
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const searchLower = searchQuery.toLowerCase();
|
const searchLower = searchQuery.toLowerCase();
|
||||||
@@ -134,7 +136,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
) : (
|
) : (
|
||||||
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} />
|
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} backups={schedules.data ?? []} />
|
||||||
)}
|
)}
|
||||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
|
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -71,8 +71,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...getVolumeOptions({ path: { name: name ?? "" } }),
|
...getVolumeOptions({ path: { name: name ?? "" } }),
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { capabilities } = useSystemInfo();
|
const { capabilities } = useSystemInfo();
|
||||||
|
|||||||
@@ -61,8 +61,6 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...listVolumesOptions(),
|
...listVolumesOptions(),
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredVolumes =
|
const filteredVolumes =
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,7 @@ import type { StatFs, Volume } from "~/client/lib/types";
|
|||||||
import { HealthchecksCard } from "../components/healthchecks-card";
|
import { HealthchecksCard } from "../components/healthchecks-card";
|
||||||
import { StorageChart } from "../components/storage-chart";
|
import { StorageChart } from "../components/storage-chart";
|
||||||
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import type { UpdateVolumeResponse } from "~/client/api-client/types.gen";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
@@ -24,12 +26,18 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
...updateVolumeMutation(),
|
...updateVolumeMutation(),
|
||||||
onSuccess: (_) => {
|
onSuccess: (data: UpdateVolumeResponse) => {
|
||||||
toast.success("Volume updated successfully");
|
toast.success("Volume updated successfully");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setPendingValues(null);
|
setPendingValues(null);
|
||||||
|
|
||||||
|
if (data.name !== volume.name) {
|
||||||
|
navigate(`/volumes/${data.name}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to update volume", { description: error.message });
|
toast.error("Failed to update volume", { description: error.message });
|
||||||
@@ -50,7 +58,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||||||
if (pendingValues) {
|
if (pendingValues) {
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
path: { name: volume.name },
|
path: { name: volume.name },
|
||||||
body: { config: pendingValues },
|
body: { name: pendingValues.name, config: pendingValues },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
1
app/drizzle/0017_fix-compression-modes.sql
Normal file
1
app/drizzle/0017_fix-compression-modes.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
UPDATE `repositories_table` SET `compression_mode` = 'auto' WHERE `compression_mode` IN ('fastest', 'better');
|
||||||
139
app/drizzle/0018_bizarre_zzzax.sql
Normal file
139
app/drizzle/0018_bizarre_zzzax.sql
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
DROP TABLE IF EXISTS `backup_schedule_mirrors_table`;--> statement-breakpoint
|
||||||
|
CREATE TABLE `backup_schedule_mirrors_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`schedule_id` integer NOT NULL,
|
||||||
|
`repository_id` text NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`last_copy_at` integer,
|
||||||
|
`last_copy_status` text,
|
||||||
|
`last_copy_error` text,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`schedule_id`) REFERENCES `backup_schedules_table`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_app_metadata` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_app_metadata`("key", "value", "created_at", "updated_at") SELECT "key", "value", "created_at", "updated_at" FROM `app_metadata`;--> statement-breakpoint
|
||||||
|
DROP TABLE `app_metadata`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_app_metadata` RENAME TO `app_metadata`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_backup_schedule_notifications_table` (
|
||||||
|
`schedule_id` integer NOT NULL,
|
||||||
|
`destination_id` integer NOT NULL,
|
||||||
|
`notify_on_start` integer DEFAULT false NOT NULL,
|
||||||
|
`notify_on_success` integer DEFAULT false NOT NULL,
|
||||||
|
`notify_on_failure` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
PRIMARY KEY(`schedule_id`, `destination_id`),
|
||||||
|
FOREIGN KEY (`schedule_id`) REFERENCES `backup_schedules_table`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`destination_id`) REFERENCES `notification_destinations_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_backup_schedule_notifications_table`("schedule_id", "destination_id", "notify_on_start", "notify_on_success", "notify_on_failure", "created_at") SELECT "schedule_id", "destination_id", "notify_on_start", "notify_on_success", "notify_on_failure", "created_at" FROM `backup_schedule_notifications_table`;--> statement-breakpoint
|
||||||
|
DROP TABLE `backup_schedule_notifications_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_backup_schedule_notifications_table` RENAME TO `backup_schedule_notifications_table`;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_backup_schedules_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`volume_id` integer NOT NULL,
|
||||||
|
`repository_id` text NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`cron_expression` text NOT NULL,
|
||||||
|
`retention_policy` text,
|
||||||
|
`exclude_patterns` text DEFAULT '[]',
|
||||||
|
`include_patterns` text DEFAULT '[]',
|
||||||
|
`last_backup_at` integer,
|
||||||
|
`last_backup_status` text,
|
||||||
|
`last_backup_error` text,
|
||||||
|
`next_backup_at` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`volume_id`) REFERENCES `volumes_table`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_backup_schedules_table`("id", "volume_id", "repository_id", "enabled", "cron_expression", "retention_policy", "exclude_patterns", "include_patterns", "last_backup_at", "last_backup_status", "last_backup_error", "next_backup_at", "created_at", "updated_at") SELECT "id", "volume_id", "repository_id", "enabled", "cron_expression", "retention_policy", "exclude_patterns", "include_patterns", "last_backup_at", "last_backup_status", "last_backup_error", "next_backup_at", "created_at", "updated_at" FROM `backup_schedules_table`;--> statement-breakpoint
|
||||||
|
DROP TABLE `backup_schedules_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_backup_schedules_table` RENAME TO `backup_schedules_table`;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_notification_destinations_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`config` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_notification_destinations_table`("id", "name", "enabled", "type", "config", "created_at", "updated_at") SELECT "id", "name", "enabled", "type", "config", "created_at", "updated_at" FROM `notification_destinations_table`;--> statement-breakpoint
|
||||||
|
DROP TABLE `notification_destinations_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_notification_destinations_table` RENAME TO `notification_destinations_table`;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `notification_destinations_table_name_unique` ON `notification_destinations_table` (`name`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_repositories_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`short_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`config` text NOT NULL,
|
||||||
|
`compression_mode` text DEFAULT 'auto',
|
||||||
|
`status` text DEFAULT 'unknown',
|
||||||
|
`last_checked` integer,
|
||||||
|
`last_error` text,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_repositories_table`("id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at") SELECT "id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at" FROM `repositories_table`;--> statement-breakpoint
|
||||||
|
DROP TABLE `repositories_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_repositories_table` RENAME TO `repositories_table`;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_sessions_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_sessions_table`("id", "user_id", "expires_at", "created_at") SELECT "id", "user_id", "expires_at", "created_at" FROM `sessions_table`;--> statement-breakpoint
|
||||||
|
DROP TABLE `sessions_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_sessions_table` RENAME TO `sessions_table`;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_users_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`has_downloaded_restic_password` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_users_table`("id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at") SELECT "id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at" FROM `users_table`;--> statement-breakpoint
|
||||||
|
DROP TABLE `users_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_volumes_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`short_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'unmounted' NOT NULL,
|
||||||
|
`last_error` text,
|
||||||
|
`last_health_check` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`config` text NOT NULL,
|
||||||
|
`auto_remount` integer DEFAULT true NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_volumes_table`("id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount") SELECT "id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount" FROM `volumes_table`;--> statement-breakpoint
|
||||||
|
DROP TABLE `volumes_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `volumes_table_name_unique` ON `volumes_table` (`name`);
|
||||||
File diff suppressed because it is too large
Load Diff
653
app/drizzle/meta/0017_snapshot.json
Normal file
653
app/drizzle/meta/0017_snapshot.json
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
{
|
||||||
|
"id": "d0bfd316-b8f5-459b-ab17-0ce679479321",
|
||||||
|
"prevId": "e50ff0fb-4111-4d20-b550-9407ee397517",
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"tables": {
|
||||||
|
"app_metadata": {
|
||||||
|
"name": "app_metadata",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"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": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"backup_schedule_notifications_table": {
|
||||||
|
"name": "backup_schedule_notifications_table",
|
||||||
|
"columns": {
|
||||||
|
"schedule_id": {
|
||||||
|
"name": "schedule_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"destination_id": {
|
||||||
|
"name": "destination_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notify_on_start": {
|
||||||
|
"name": "notify_on_start",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notify_on_success": {
|
||||||
|
"name": "notify_on_success",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notify_on_failure": {
|
||||||
|
"name": "notify_on_failure",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
|
||||||
|
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedule_notifications_table",
|
||||||
|
"columnsFrom": ["schedule_id"],
|
||||||
|
"tableTo": "backup_schedules_table",
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onUpdate": "no action",
|
||||||
|
"onDelete": "cascade"
|
||||||
|
},
|
||||||
|
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
|
||||||
|
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedule_notifications_table",
|
||||||
|
"columnsFrom": ["destination_id"],
|
||||||
|
"tableTo": "notification_destinations_table",
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onUpdate": "no action",
|
||||||
|
"onDelete": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
|
||||||
|
"columns": ["schedule_id", "destination_id"],
|
||||||
|
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"columnsFrom": ["volume_id"],
|
||||||
|
"tableTo": "volumes_table",
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onUpdate": "no action",
|
||||||
|
"onDelete": "cascade"
|
||||||
|
},
|
||||||
|
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"tableTo": "repositories_table",
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onUpdate": "no action",
|
||||||
|
"onDelete": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"notification_destinations_table": {
|
||||||
|
"name": "notification_destinations_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"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": {
|
||||||
|
"notification_destinations_table_name_unique": {
|
||||||
|
"name": "notification_destinations_table_name_unique",
|
||||||
|
"columns": ["name"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"repositories_table": {
|
||||||
|
"name": "repositories_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"short_id": {
|
||||||
|
"name": "short_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"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_short_id_unique": {
|
||||||
|
"name": "repositories_table_short_id_unique",
|
||||||
|
"columns": ["short_id"],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onUpdate": "no action",
|
||||||
|
"onDelete": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"short_id": {
|
||||||
|
"name": "short_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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_short_id_unique": {
|
||||||
|
"name": "volumes_table_short_id_unique",
|
||||||
|
"columns": ["short_id"],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"volumes_table_name_unique": {
|
||||||
|
"name": "volumes_table_name_unique",
|
||||||
|
"columns": ["name"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
740
app/drizzle/meta/0018_snapshot.json
Normal file
740
app/drizzle/meta/0018_snapshot.json
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "121ef03c-eb5a-4b97-b2f1-4add6adfb080",
|
||||||
|
"prevId": "d0bfd316-b8f5-459b-ab17-0ce679479321",
|
||||||
|
"tables": {
|
||||||
|
"app_metadata": {
|
||||||
|
"name": "app_metadata",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"backup_schedule_mirrors_table": {
|
||||||
|
"name": "backup_schedule_mirrors_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"schedule_id": {
|
||||||
|
"name": "schedule_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
|
||||||
|
},
|
||||||
|
"last_copy_at": {
|
||||||
|
"name": "last_copy_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_copy_status": {
|
||||||
|
"name": "last_copy_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_copy_error": {
|
||||||
|
"name": "last_copy_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
|
||||||
|
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedule_mirrors_table",
|
||||||
|
"tableTo": "backup_schedules_table",
|
||||||
|
"columnsFrom": ["schedule_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
|
||||||
|
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedule_mirrors_table",
|
||||||
|
"tableTo": "repositories_table",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"backup_schedule_notifications_table": {
|
||||||
|
"name": "backup_schedule_notifications_table",
|
||||||
|
"columns": {
|
||||||
|
"schedule_id": {
|
||||||
|
"name": "schedule_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"destination_id": {
|
||||||
|
"name": "destination_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notify_on_start": {
|
||||||
|
"name": "notify_on_start",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notify_on_success": {
|
||||||
|
"name": "notify_on_success",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notify_on_failure": {
|
||||||
|
"name": "notify_on_failure",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
|
||||||
|
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedule_notifications_table",
|
||||||
|
"tableTo": "backup_schedules_table",
|
||||||
|
"columnsFrom": ["schedule_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
|
||||||
|
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedule_notifications_table",
|
||||||
|
"tableTo": "notification_destinations_table",
|
||||||
|
"columnsFrom": ["destination_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
|
||||||
|
"columns": ["schedule_id", "destination_id"],
|
||||||
|
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"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() * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"notification_destinations_table": {
|
||||||
|
"name": "notification_destinations_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"notification_destinations_table_name_unique": {
|
||||||
|
"name": "notification_destinations_table_name_unique",
|
||||||
|
"columns": ["name"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"repositories_table": {
|
||||||
|
"name": "repositories_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"short_id": {
|
||||||
|
"name": "short_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"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() * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"repositories_table_short_id_unique": {
|
||||||
|
"name": "repositories_table_short_id_unique",
|
||||||
|
"columns": ["short_id"],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"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() * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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() * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"short_id": {
|
||||||
|
"name": "short_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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() * 1000)"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch() * 1000)"
|
||||||
|
},
|
||||||
|
"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_short_id_unique": {
|
||||||
|
"name": "volumes_table_short_id_unique",
|
||||||
|
"columns": ["short_id"],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,125 +1,139 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1755765658194,
|
"when": 1755765658194,
|
||||||
"tag": "0000_known_madelyne_pryor",
|
"tag": "0000_known_madelyne_pryor",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1755775437391,
|
"when": 1755775437391,
|
||||||
"tag": "0001_far_frank_castle",
|
"tag": "0001_far_frank_castle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1756930554198,
|
"when": 1756930554198,
|
||||||
"tag": "0002_cheerful_randall",
|
"tag": "0002_cheerful_randall",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1758653407064,
|
"when": 1758653407064,
|
||||||
"tag": "0003_mature_hellcat",
|
"tag": "0003_mature_hellcat",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 4,
|
"idx": 4,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1758961535488,
|
"when": 1758961535488,
|
||||||
"tag": "0004_wealthy_tomas",
|
"tag": "0004_wealthy_tomas",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 5,
|
"idx": 5,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1759416698274,
|
"when": 1759416698274,
|
||||||
"tag": "0005_simple_alice",
|
"tag": "0005_simple_alice",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 6,
|
"idx": 6,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1760734377440,
|
"when": 1760734377440,
|
||||||
"tag": "0006_secret_micromacro",
|
"tag": "0006_secret_micromacro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1761224911352,
|
"when": 1761224911352,
|
||||||
"tag": "0007_watery_sersi",
|
"tag": "0007_watery_sersi",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 8,
|
"idx": 8,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1761414054481,
|
"when": 1761414054481,
|
||||||
"tag": "0008_silent_lady_bullseye",
|
"tag": "0008_silent_lady_bullseye",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 9,
|
"idx": 9,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1762095226041,
|
"when": 1762095226041,
|
||||||
"tag": "0009_little_adam_warlock",
|
"tag": "0009_little_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 10,
|
"idx": 10,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1762610065889,
|
"when": 1762610065889,
|
||||||
"tag": "0010_perfect_proemial_gods",
|
"tag": "0010_perfect_proemial_gods",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 11,
|
"idx": 11,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1763644043601,
|
"when": 1763644043601,
|
||||||
"tag": "0011_familiar_stone_men",
|
"tag": "0011_familiar_stone_men",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 12,
|
"idx": 12,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764100562084,
|
"when": 1764100562084,
|
||||||
"tag": "0012_add_short_ids",
|
"tag": "0012_add_short_ids",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 13,
|
"idx": 13,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764182159797,
|
"when": 1764182159797,
|
||||||
"tag": "0013_elite_sprite",
|
"tag": "0013_elite_sprite",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 14,
|
"idx": 14,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764182405089,
|
"when": 1764182405089,
|
||||||
"tag": "0014_wild_echo",
|
"tag": "0014_wild_echo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 15,
|
"idx": 15,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764182465287,
|
"when": 1764182465287,
|
||||||
"tag": "0015_jazzy_sersi",
|
"tag": "0015_jazzy_sersi",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 16,
|
"idx": 16,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764194697035,
|
"when": 1764194697035,
|
||||||
"tag": "0016_fix-timestamps-to-ms",
|
"tag": "0016_fix-timestamps-to-ms",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
}
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764357897219,
|
||||||
|
"tag": "0017_fix-compression-modes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764619898949,
|
||||||
|
"tag": "0018_bizarre_zzzax",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ export default [
|
|||||||
route("backups", "./client/modules/backups/routes/backups.tsx"),
|
route("backups", "./client/modules/backups/routes/backups.tsx"),
|
||||||
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
|
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
|
||||||
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
|
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
|
||||||
|
route("backups/:id/:snapshotId/restore", "./client/modules/backups/routes/restore-snapshot.tsx"),
|
||||||
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
|
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
|
||||||
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
|
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
|
||||||
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
|
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
|
||||||
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
||||||
|
route("repositories/:name/:snapshotId/restore", "./client/modules/repositories/routes/restore-snapshot.tsx"),
|
||||||
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
|
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
|
||||||
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
|
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
|
||||||
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),
|
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const NOTIFICATION_TYPES = {
|
|||||||
gotify: "gotify",
|
gotify: "gotify",
|
||||||
ntfy: "ntfy",
|
ntfy: "ntfy",
|
||||||
pushover: "pushover",
|
pushover: "pushover",
|
||||||
|
telegram: "telegram",
|
||||||
custom: "custom",
|
custom: "custom",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -64,6 +65,12 @@ export const pushoverNotificationConfigSchema = type({
|
|||||||
priority: "-1 | 0 | 1",
|
priority: "-1 | 0 | 1",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const telegramNotificationConfigSchema = type({
|
||||||
|
type: "'telegram'",
|
||||||
|
botToken: "string",
|
||||||
|
chatId: "string",
|
||||||
|
});
|
||||||
|
|
||||||
export const customNotificationConfigSchema = type({
|
export const customNotificationConfigSchema = type({
|
||||||
type: "'custom'",
|
type: "'custom'",
|
||||||
shoutrrrUrl: "string",
|
shoutrrrUrl: "string",
|
||||||
@@ -75,6 +82,7 @@ export const notificationConfigSchema = emailNotificationConfigSchema
|
|||||||
.or(gotifyNotificationConfigSchema)
|
.or(gotifyNotificationConfigSchema)
|
||||||
.or(ntfyNotificationConfigSchema)
|
.or(ntfyNotificationConfigSchema)
|
||||||
.or(pushoverNotificationConfigSchema)
|
.or(pushoverNotificationConfigSchema)
|
||||||
|
.or(telegramNotificationConfigSchema)
|
||||||
.or(customNotificationConfigSchema);
|
.or(customNotificationConfigSchema);
|
||||||
|
|
||||||
export type NotificationConfig = typeof notificationConfigSchema.infer;
|
export type NotificationConfig = typeof notificationConfigSchema.infer;
|
||||||
|
|||||||
@@ -93,8 +93,6 @@ export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
|||||||
export const COMPRESSION_MODES = {
|
export const COMPRESSION_MODES = {
|
||||||
off: "off",
|
off: "off",
|
||||||
auto: "auto",
|
auto: "auto",
|
||||||
fastest: "fastest",
|
|
||||||
better: "better",
|
|
||||||
max: "max",
|
max: "max",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -107,3 +105,12 @@ export const REPOSITORY_STATUS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
||||||
|
|
||||||
|
export const OVERWRITE_MODES = {
|
||||||
|
always: "always",
|
||||||
|
ifChanged: "if-changed",
|
||||||
|
ifNewer: "if-newer",
|
||||||
|
never: "never",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OverwriteMode = (typeof OVERWRITE_MODES)[keyof typeof OVERWRITE_MODES];
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ interface ServerEvents {
|
|||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error" | "stopped" | "warning";
|
status: "success" | "error" | "stopped" | "warning";
|
||||||
}) => void;
|
}) => void;
|
||||||
|
"mirror:started": (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => void;
|
||||||
|
"mirror:completed": (data: {
|
||||||
|
scheduleId: number;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryName: string;
|
||||||
|
status: "success" | "error";
|
||||||
|
error?: string;
|
||||||
|
}) => 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;
|
||||||
"volume:updated": (data: { volumeName: string }) => void;
|
"volume:updated": (data: { volumeName: string }) => void;
|
||||||
|
|||||||
180
app/server/core/repository-mutex.ts
Normal file
180
app/server/core/repository-mutex.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export type LockType = "shared" | "exclusive";
|
||||||
|
|
||||||
|
interface LockHolder {
|
||||||
|
id: string;
|
||||||
|
operation: string;
|
||||||
|
acquiredAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepositoryLockState {
|
||||||
|
sharedHolders: Map<string, LockHolder>;
|
||||||
|
exclusiveHolder: LockHolder | null;
|
||||||
|
waitQueue: Array<{
|
||||||
|
type: LockType;
|
||||||
|
operation: string;
|
||||||
|
resolve: (lockId: string) => void;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RepositoryMutex {
|
||||||
|
private locks = new Map<string, RepositoryLockState>();
|
||||||
|
private lockIdCounter = 0;
|
||||||
|
|
||||||
|
private getOrCreateState(repositoryId: string): RepositoryLockState {
|
||||||
|
let state = this.locks.get(repositoryId);
|
||||||
|
if (!state) {
|
||||||
|
state = {
|
||||||
|
sharedHolders: new Map(),
|
||||||
|
exclusiveHolder: null,
|
||||||
|
waitQueue: [],
|
||||||
|
};
|
||||||
|
this.locks.set(repositoryId, state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateLockId(): string {
|
||||||
|
return `lock_${++this.lockIdCounter}_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupStateIfEmpty(repositoryId: string): void {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (state && state.sharedHolders.size === 0 && !state.exclusiveHolder && state.waitQueue.length === 0) {
|
||||||
|
this.locks.delete(repositoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquireShared(repositoryId: string, operation: string): Promise<() => void> {
|
||||||
|
const state = this.getOrCreateState(repositoryId);
|
||||||
|
|
||||||
|
if (!state.exclusiveHolder) {
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
state.sharedHolders.set(lockId, {
|
||||||
|
id: lockId,
|
||||||
|
operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
});
|
||||||
|
return () => this.releaseShared(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation} (exclusive held by: ${state.exclusiveHolder.operation})`,
|
||||||
|
);
|
||||||
|
const lockId = await new Promise<string>((resolve) => {
|
||||||
|
state.waitQueue.push({ type: "shared", operation, resolve });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => this.releaseShared(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquireExclusive(repositoryId: string, operation: string): Promise<() => void> {
|
||||||
|
const state = this.getOrCreateState(repositoryId);
|
||||||
|
|
||||||
|
if (!state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0) {
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
state.exclusiveHolder = {
|
||||||
|
id: lockId,
|
||||||
|
operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
};
|
||||||
|
return () => this.releaseExclusive(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation} (shared: ${state.sharedHolders.size}, exclusive: ${state.exclusiveHolder ? "yes" : "no"}, queue: ${state.waitQueue.length})`,
|
||||||
|
);
|
||||||
|
const lockId = await new Promise<string>((resolve) => {
|
||||||
|
state.waitQueue.push({ type: "exclusive", operation, resolve });
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation} (${lockId})`);
|
||||||
|
return () => this.releaseExclusive(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseShared(repositoryId: string, lockId: string): void {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holder = state.sharedHolders.get(lockId);
|
||||||
|
if (!holder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.sharedHolders.delete(lockId);
|
||||||
|
const duration = Date.now() - holder.acquiredAt;
|
||||||
|
logger.debug(`[Mutex] Released shared lock for repo ${repositoryId}: ${holder.operation} (held for ${duration}ms)`);
|
||||||
|
|
||||||
|
this.processWaitQueue(repositoryId);
|
||||||
|
this.cleanupStateIfEmpty(repositoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseExclusive(repositoryId: string, lockId: string): void {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.exclusiveHolder || state.exclusiveHolder.id !== lockId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - state.exclusiveHolder.acquiredAt;
|
||||||
|
logger.debug(
|
||||||
|
`[Mutex] Released exclusive lock for repo ${repositoryId}: ${state.exclusiveHolder.operation} (held for ${duration}ms)`,
|
||||||
|
);
|
||||||
|
state.exclusiveHolder = null;
|
||||||
|
|
||||||
|
this.processWaitQueue(repositoryId);
|
||||||
|
this.cleanupStateIfEmpty(repositoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private processWaitQueue(repositoryId: string): void {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (!state || state.waitQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.exclusiveHolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstWaiter = state.waitQueue[0];
|
||||||
|
|
||||||
|
if (firstWaiter.type === "exclusive") {
|
||||||
|
if (state.sharedHolders.size === 0) {
|
||||||
|
state.waitQueue.shift();
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
state.exclusiveHolder = {
|
||||||
|
id: lockId,
|
||||||
|
operation: firstWaiter.operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
};
|
||||||
|
firstWaiter.resolve(lockId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (state.waitQueue.length > 0 && state.waitQueue[0].type === "shared") {
|
||||||
|
const waiter = state.waitQueue.shift();
|
||||||
|
if (!waiter) break;
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
state.sharedHolders.set(lockId, {
|
||||||
|
id: lockId,
|
||||||
|
operation: waiter.operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
});
|
||||||
|
waiter.resolve(lockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocked(repositoryId: string): boolean {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (!state) return false;
|
||||||
|
return state.exclusiveHolder !== null || state.sharedHolders.size > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const repoMutex = new RepositoryMutex();
|
||||||
@@ -93,6 +93,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
|
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
|
||||||
volume: one(volumesTable, {
|
volume: one(volumesTable, {
|
||||||
fields: [backupSchedulesTable.volumeId],
|
fields: [backupSchedulesTable.volumeId],
|
||||||
@@ -103,6 +104,7 @@ export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, m
|
|||||||
references: [repositoriesTable.id],
|
references: [repositoriesTable.id],
|
||||||
}),
|
}),
|
||||||
notifications: many(backupScheduleNotificationsTable),
|
notifications: many(backupScheduleNotificationsTable),
|
||||||
|
mirrors: many(backupScheduleMirrorsTable),
|
||||||
}));
|
}));
|
||||||
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
||||||
|
|
||||||
@@ -154,6 +156,37 @@ export const backupScheduleNotificationRelations = relations(backupScheduleNotif
|
|||||||
}));
|
}));
|
||||||
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
|
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup Schedule Mirrors Junction Table (Many-to-Many)
|
||||||
|
* Allows copying snapshots to secondary repositories after backup completes
|
||||||
|
*/
|
||||||
|
export const backupScheduleMirrorsTable = sqliteTable("backup_schedule_mirrors_table", {
|
||||||
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
|
scheduleId: int("schedule_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => backupSchedulesTable.id, { onDelete: "cascade" }),
|
||||||
|
repositoryId: text("repository_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => repositoriesTable.id, { onDelete: "cascade" }),
|
||||||
|
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
lastCopyAt: int("last_copy_at", { mode: "number" }),
|
||||||
|
lastCopyStatus: text("last_copy_status").$type<"success" | "error">(),
|
||||||
|
lastCopyError: text("last_copy_error"),
|
||||||
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
|
||||||
|
schedule: one(backupSchedulesTable, {
|
||||||
|
fields: [backupScheduleMirrorsTable.scheduleId],
|
||||||
|
references: [backupSchedulesTable.id],
|
||||||
|
}),
|
||||||
|
repository: one(repositoriesTable, {
|
||||||
|
fields: [backupScheduleMirrorsTable.repositoryId],
|
||||||
|
references: [repositoriesTable.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelect;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App Metadata Table
|
* App Metadata Table
|
||||||
* Used for storing key-value pairs like migration checkpoints
|
* Used for storing key-value pairs like migration checkpoints
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { logger } from "../utils/logger";
|
|||||||
import { db } from "../db/db";
|
import { db } from "../db/db";
|
||||||
import { eq, or } from "drizzle-orm";
|
import { eq, or } from "drizzle-orm";
|
||||||
import { repositoriesTable } from "../db/schema";
|
import { repositoriesTable } from "../db/schema";
|
||||||
|
import { repoMutex } from "../core/repository-mutex";
|
||||||
|
|
||||||
export class RepositoryHealthCheckJob extends Job {
|
export class RepositoryHealthCheckJob extends Job {
|
||||||
async run() {
|
async run() {
|
||||||
@@ -14,6 +15,11 @@ export class RepositoryHealthCheckJob extends Job {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const repository of repositories) {
|
for (const repository of repositories) {
|
||||||
|
if (repoMutex.isLocked(repository.id)) {
|
||||||
|
logger.debug(`Skipping health check for repository ${repository.name}: currently locked`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await repositoriesService.checkHealth(repository.id);
|
await repositoriesService.checkHealth(repository.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from "./auth.dto";
|
} from "./auth.dto";
|
||||||
import { authService } from "./auth.service";
|
import { authService } from "./auth.service";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { logger } from "~/server/utils/logger";
|
|
||||||
|
|
||||||
const COOKIE_NAME = "session_id";
|
const COOKIE_NAME = "session_id";
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
stopBackupDto,
|
stopBackupDto,
|
||||||
updateBackupScheduleDto,
|
updateBackupScheduleDto,
|
||||||
updateBackupScheduleBody,
|
updateBackupScheduleBody,
|
||||||
|
getScheduleMirrorsDto,
|
||||||
|
updateScheduleMirrorsDto,
|
||||||
|
updateScheduleMirrorsBody,
|
||||||
|
getMirrorCompatibilityDto,
|
||||||
type CreateBackupScheduleDto,
|
type CreateBackupScheduleDto,
|
||||||
type DeleteBackupScheduleDto,
|
type DeleteBackupScheduleDto,
|
||||||
type GetBackupScheduleDto,
|
type GetBackupScheduleDto,
|
||||||
@@ -21,6 +25,9 @@ import {
|
|||||||
type RunForgetDto,
|
type RunForgetDto,
|
||||||
type StopBackupDto,
|
type StopBackupDto,
|
||||||
type UpdateBackupScheduleDto,
|
type UpdateBackupScheduleDto,
|
||||||
|
type GetScheduleMirrorsDto,
|
||||||
|
type UpdateScheduleMirrorsDto,
|
||||||
|
type GetMirrorCompatibilityDto,
|
||||||
} from "./backups.dto";
|
} from "./backups.dto";
|
||||||
import { backupsService } from "./backups.service";
|
import { backupsService } from "./backups.service";
|
||||||
import {
|
import {
|
||||||
@@ -113,4 +120,23 @@ export const backupScheduleController = new Hono()
|
|||||||
|
|
||||||
return c.json<UpdateScheduleNotificationsDto>(assignments, 200);
|
return c.json<UpdateScheduleNotificationsDto>(assignments, 200);
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
.get("/:scheduleId/mirrors", getScheduleMirrorsDto, async (c) => {
|
||||||
|
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
|
||||||
|
const mirrors = await backupsService.getMirrors(scheduleId);
|
||||||
|
|
||||||
|
return c.json<GetScheduleMirrorsDto>(mirrors, 200);
|
||||||
|
})
|
||||||
|
.put("/:scheduleId/mirrors", updateScheduleMirrorsDto, validator("json", updateScheduleMirrorsBody), async (c) => {
|
||||||
|
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const mirrors = await backupsService.updateMirrors(scheduleId, body);
|
||||||
|
|
||||||
|
return c.json<UpdateScheduleMirrorsDto>(mirrors, 200);
|
||||||
|
})
|
||||||
|
.get("/:scheduleId/mirrors/compatibility", getMirrorCompatibilityDto, async (c) => {
|
||||||
|
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
|
||||||
|
const compatibility = await backupsService.getMirrorCompatibility(scheduleId);
|
||||||
|
|
||||||
|
return c.json<GetMirrorCompatibilityDto>(compatibility, 200);
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ const backupScheduleSchema = type({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scheduleMirrorSchema = type({
|
||||||
|
scheduleId: "number",
|
||||||
|
repositoryId: "string",
|
||||||
|
enabled: "boolean",
|
||||||
|
lastCopyAt: "number | null",
|
||||||
|
lastCopyStatus: "'success' | 'error' | null",
|
||||||
|
lastCopyError: "string | null",
|
||||||
|
createdAt: "number",
|
||||||
|
repository: repositorySchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ScheduleMirrorDto = typeof scheduleMirrorSchema.infer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backup schedules
|
* List all backup schedules
|
||||||
*/
|
*/
|
||||||
@@ -276,3 +289,75 @@ export const runForgetDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getScheduleMirrorsResponse = scheduleMirrorSchema.array();
|
||||||
|
export type GetScheduleMirrorsDto = typeof getScheduleMirrorsResponse.infer;
|
||||||
|
|
||||||
|
export const getScheduleMirrorsDto = describeRoute({
|
||||||
|
description: "Get mirror repository assignments for a backup schedule",
|
||||||
|
operationId: "getScheduleMirrors",
|
||||||
|
tags: ["Backups"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of mirror repository assignments for the schedule",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(getScheduleMirrorsResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateScheduleMirrorsBody = type({
|
||||||
|
mirrors: type({
|
||||||
|
repositoryId: "string",
|
||||||
|
enabled: "boolean",
|
||||||
|
}).array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateScheduleMirrorsBody = typeof updateScheduleMirrorsBody.infer;
|
||||||
|
|
||||||
|
export const updateScheduleMirrorsResponse = scheduleMirrorSchema.array();
|
||||||
|
export type UpdateScheduleMirrorsDto = typeof updateScheduleMirrorsResponse.infer;
|
||||||
|
|
||||||
|
export const updateScheduleMirrorsDto = describeRoute({
|
||||||
|
description: "Update mirror repository assignments for a backup schedule",
|
||||||
|
operationId: "updateScheduleMirrors",
|
||||||
|
tags: ["Backups"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Mirror assignments updated successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(updateScheduleMirrorsResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mirrorCompatibilitySchema = type({
|
||||||
|
repositoryId: "string",
|
||||||
|
compatible: "boolean",
|
||||||
|
reason: "string | null",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getMirrorCompatibilityResponse = mirrorCompatibilitySchema.array();
|
||||||
|
export type GetMirrorCompatibilityDto = typeof getMirrorCompatibilityResponse.infer;
|
||||||
|
|
||||||
|
export const getMirrorCompatibilityDto = describeRoute({
|
||||||
|
description: "Get mirror compatibility info for all repositories relative to a backup schedule's primary repository",
|
||||||
|
operationId: "getMirrorCompatibility",
|
||||||
|
tags: ["Backups"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of repositories with their mirror compatibility status",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(getMirrorCompatibilityResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import cron from "node-cron";
|
|||||||
import { CronExpressionParser } from "cron-parser";
|
import { CronExpressionParser } from "cron-parser";
|
||||||
import { NotFoundError, BadRequestError, ConflictError } 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, backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { getVolumePath } from "../volumes/helpers";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
import { notificationsService } from "../notifications/notifications.service";
|
import { notificationsService } from "../notifications/notifications.service";
|
||||||
|
import { repoMutex } from "../../core/repository-mutex";
|
||||||
|
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
|
||||||
|
|
||||||
const runningBackups = new Map<number, AbortController>();
|
const runningBackups = new Map<number, AbortController>();
|
||||||
|
|
||||||
@@ -241,36 +243,49 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
backupOptions.include = schedule.includePatterns;
|
backupOptions.include = schedule.includePatterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { exitCode } = await restic.backup(repository.config, volumePath, {
|
const releaseBackupLock = await repoMutex.acquireShared(repository.id, `backup:${volume.name}`);
|
||||||
...backupOptions,
|
let exitCode: number;
|
||||||
compressionMode: repository.compressionMode ?? "auto",
|
try {
|
||||||
onProgress: (progress) => {
|
const result = await restic.backup(repository.config, volumePath, {
|
||||||
serverEvents.emit("backup:progress", {
|
...backupOptions,
|
||||||
scheduleId,
|
compressionMode: repository.compressionMode ?? "auto",
|
||||||
volumeName: volume.name,
|
onProgress: (progress) => {
|
||||||
repositoryName: repository.name,
|
serverEvents.emit("backup:progress", {
|
||||||
...progress,
|
scheduleId,
|
||||||
});
|
volumeName: volume.name,
|
||||||
},
|
repositoryName: repository.name,
|
||||||
});
|
...progress,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
exitCode = result.exitCode;
|
||||||
|
} finally {
|
||||||
|
releaseBackupLock();
|
||||||
|
}
|
||||||
|
|
||||||
if (schedule.retentionPolicy) {
|
if (schedule.retentionPolicy) {
|
||||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
void runForget(schedule.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copyToMirrors(scheduleId, repository, schedule.retentionPolicy).catch((error) => {
|
||||||
|
logger.error(`Background mirror copy failed for schedule ${scheduleId}: ${toMessage(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalStatus = exitCode === 0 ? "success" : "warning";
|
||||||
|
|
||||||
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
||||||
await db
|
await db
|
||||||
.update(backupSchedulesTable)
|
.update(backupSchedulesTable)
|
||||||
.set({
|
.set({
|
||||||
lastBackupAt: Date.now(),
|
lastBackupAt: Date.now(),
|
||||||
lastBackupStatus: exitCode === 0 ? "success" : "warning",
|
lastBackupStatus: finalStatus,
|
||||||
lastBackupError: null,
|
lastBackupError: null,
|
||||||
nextBackupAt: nextBackupAt,
|
nextBackupAt: nextBackupAt,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (finalStatus === "warning") {
|
||||||
logger.warn(`Backup completed with warnings for volume ${volume.name} to repository ${repository.name}`);
|
logger.warn(`Backup completed with warnings for volume ${volume.name} to repository ${repository.name}`);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
|
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
|
||||||
@@ -280,11 +295,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
scheduleId,
|
scheduleId,
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
status: exitCode === 0 ? "success" : "warning",
|
status: finalStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationsService
|
notificationsService
|
||||||
.sendBackupNotification(scheduleId, exitCode === 0 ? "success" : "warning", {
|
.sendBackupNotification(scheduleId, finalStatus === "success" ? "success" : "warning", {
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
})
|
})
|
||||||
@@ -402,11 +417,173 @@ const runForget = async (scheduleId: number) => {
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Manually running retention policy (forget) for schedule ${scheduleId}`);
|
logger.info(`running retention policy (forget) for schedule ${scheduleId}`);
|
||||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:manual:${scheduleId}`);
|
||||||
|
try {
|
||||||
|
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`);
|
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMirrors = async (scheduleId: number) => {
|
||||||
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
|
where: eq(backupSchedulesTable.id, scheduleId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mirrors = await db.query.backupScheduleMirrorsTable.findMany({
|
||||||
|
where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
|
||||||
|
with: { repository: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return mirrors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMirrors = async (scheduleId: number, data: UpdateScheduleMirrorsBody) => {
|
||||||
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
|
where: eq(backupSchedulesTable.id, scheduleId),
|
||||||
|
with: { repository: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mirror of data.mirrors) {
|
||||||
|
if (mirror.repositoryId === schedule.repositoryId) {
|
||||||
|
throw new BadRequestError("Cannot add the primary repository as a mirror");
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: eq(repositoriesTable.id, mirror.repositoryId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repo) {
|
||||||
|
throw new NotFoundError(`Repository ${mirror.repositoryId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compatibility = await checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id);
|
||||||
|
|
||||||
|
if (!compatibility.compatible) {
|
||||||
|
throw new BadRequestError(
|
||||||
|
getIncompatibleMirrorError(repo.name, schedule.repository.config.backend, repo.config.backend),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(backupScheduleMirrorsTable).where(eq(backupScheduleMirrorsTable.scheduleId, scheduleId));
|
||||||
|
|
||||||
|
if (data.mirrors.length > 0) {
|
||||||
|
await db.insert(backupScheduleMirrorsTable).values(
|
||||||
|
data.mirrors.map((mirror) => ({
|
||||||
|
scheduleId,
|
||||||
|
repositoryId: mirror.repositoryId,
|
||||||
|
enabled: mirror.enabled,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMirrors(scheduleId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToMirrors = async (
|
||||||
|
scheduleId: number,
|
||||||
|
sourceRepository: { id: string; config: (typeof repositoriesTable.$inferSelect)["config"] },
|
||||||
|
retentionPolicy: (typeof backupSchedulesTable.$inferSelect)["retentionPolicy"],
|
||||||
|
) => {
|
||||||
|
const mirrors = await db.query.backupScheduleMirrorsTable.findMany({
|
||||||
|
where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
|
||||||
|
with: { repository: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledMirrors = mirrors.filter((m) => m.enabled);
|
||||||
|
|
||||||
|
if (enabledMirrors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[Background] Copying snapshots to ${enabledMirrors.length} mirror repositories for schedule ${scheduleId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const mirror of enabledMirrors) {
|
||||||
|
try {
|
||||||
|
logger.info(`[Background] Copying to mirror repository: ${mirror.repository.name}`);
|
||||||
|
|
||||||
|
serverEvents.emit("mirror:started", {
|
||||||
|
scheduleId,
|
||||||
|
repositoryId: mirror.repositoryId,
|
||||||
|
repositoryName: mirror.repository.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
await restic.copy(sourceRepository.config, mirror.repository.config, {
|
||||||
|
tag: scheduleId.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (retentionPolicy) {
|
||||||
|
logger.info(`[Background] Applying retention policy to mirror repository: ${mirror.repository.name}`);
|
||||||
|
await restic.forget(mirror.repository.config, retentionPolicy, { tag: scheduleId.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(backupScheduleMirrorsTable)
|
||||||
|
.set({ lastCopyAt: Date.now(), lastCopyStatus: "success", lastCopyError: null })
|
||||||
|
.where(eq(backupScheduleMirrorsTable.id, mirror.id));
|
||||||
|
|
||||||
|
logger.info(`[Background] Successfully copied to mirror repository: ${mirror.repository.name}`);
|
||||||
|
|
||||||
|
serverEvents.emit("mirror:completed", {
|
||||||
|
scheduleId,
|
||||||
|
repositoryId: mirror.repositoryId,
|
||||||
|
repositoryName: mirror.repository.name,
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = toMessage(error);
|
||||||
|
logger.error(`[Background] Failed to copy to mirror repository ${mirror.repository.name}: ${errorMessage}`);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(backupScheduleMirrorsTable)
|
||||||
|
.set({ lastCopyAt: Date.now(), lastCopyStatus: "error", lastCopyError: errorMessage })
|
||||||
|
.where(eq(backupScheduleMirrorsTable.id, mirror.id));
|
||||||
|
|
||||||
|
serverEvents.emit("mirror:completed", {
|
||||||
|
scheduleId,
|
||||||
|
repositoryId: mirror.repositoryId,
|
||||||
|
repositoryName: mirror.repository.name,
|
||||||
|
status: "error",
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMirrorCompatibility = async (scheduleId: number) => {
|
||||||
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
|
where: eq(backupSchedulesTable.id, scheduleId),
|
||||||
|
with: { repository: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRepositories = await db.query.repositoriesTable.findMany();
|
||||||
|
const repos = allRepositories.filter((repo) => repo.id !== schedule.repositoryId);
|
||||||
|
|
||||||
|
const compatibility = await Promise.all(
|
||||||
|
repos.map((repo) => checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return compatibility;
|
||||||
|
};
|
||||||
|
|
||||||
export const backupsService = {
|
export const backupsService = {
|
||||||
listSchedules,
|
listSchedules,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
@@ -418,4 +595,7 @@ export const backupsService = {
|
|||||||
getScheduleForVolume,
|
getScheduleForVolume,
|
||||||
stopBackup,
|
stopBackup,
|
||||||
runForget,
|
runForget,
|
||||||
|
getMirrors,
|
||||||
|
updateMirrors,
|
||||||
|
getMirrorCompatibility,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,12 +70,34 @@ export const eventsController = new Hono().get("/", (c) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onMirrorStarted = (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
event: "mirror:started",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMirrorCompleted = (data: {
|
||||||
|
scheduleId: number;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryName: string;
|
||||||
|
status: "success" | "error";
|
||||||
|
error?: string;
|
||||||
|
}) => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
event: "mirror:completed",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
serverEvents.on("backup:started", onBackupStarted);
|
serverEvents.on("backup:started", onBackupStarted);
|
||||||
serverEvents.on("backup:progress", onBackupProgress);
|
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);
|
||||||
serverEvents.on("volume:updated", onVolumeUpdated);
|
serverEvents.on("volume:updated", onVolumeUpdated);
|
||||||
|
serverEvents.on("mirror:started", onMirrorStarted);
|
||||||
|
serverEvents.on("mirror:completed", onMirrorCompleted);
|
||||||
|
|
||||||
let keepAlive = true;
|
let keepAlive = true;
|
||||||
|
|
||||||
@@ -88,6 +110,8 @@ export const eventsController = new Hono().get("/", (c) => {
|
|||||||
serverEvents.off("volume:mounted", onVolumeMounted);
|
serverEvents.off("volume:mounted", onVolumeMounted);
|
||||||
serverEvents.off("volume:unmounted", onVolumeUnmounted);
|
serverEvents.off("volume:unmounted", onVolumeUnmounted);
|
||||||
serverEvents.off("volume:updated", onVolumeUpdated);
|
serverEvents.off("volume:updated", onVolumeUpdated);
|
||||||
|
serverEvents.off("mirror:started", onMirrorStarted);
|
||||||
|
serverEvents.off("mirror:completed", onMirrorCompleted);
|
||||||
});
|
});
|
||||||
|
|
||||||
while (keepAlive) {
|
while (keepAlive) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { eq, sql } from "drizzle-orm";
|
|||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { appMetadataTable, usersTable } from "../../db/schema";
|
import { appMetadataTable, usersTable } from "../../db/schema";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { REQUIRED_MIGRATIONS } from "~/server/core/constants";
|
|
||||||
|
|
||||||
const MIGRATION_KEY_PREFIX = "migration:";
|
const MIGRATION_KEY_PREFIX = "migration:";
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const startup = async () => {
|
|||||||
|
|
||||||
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
||||||
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
|
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
|
||||||
Scheduler.build(RepositoryHealthCheckJob).schedule("0 * * * *");
|
Scheduler.build(RepositoryHealthCheckJob).schedule("50 12 * * *");
|
||||||
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
||||||
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { buildDiscordShoutrrrUrl } from "./discord";
|
|||||||
import { buildGotifyShoutrrrUrl } from "./gotify";
|
import { buildGotifyShoutrrrUrl } from "./gotify";
|
||||||
import { buildNtfyShoutrrrUrl } from "./ntfy";
|
import { buildNtfyShoutrrrUrl } from "./ntfy";
|
||||||
import { buildPushoverShoutrrrUrl } from "./pushover";
|
import { buildPushoverShoutrrrUrl } from "./pushover";
|
||||||
|
import { buildTelegramShoutrrrUrl } from "./telegram";
|
||||||
import { buildCustomShoutrrrUrl } from "./custom";
|
import { buildCustomShoutrrrUrl } from "./custom";
|
||||||
|
|
||||||
export function buildShoutrrrUrl(config: NotificationConfig): string {
|
export function buildShoutrrrUrl(config: NotificationConfig): string {
|
||||||
@@ -21,6 +22,8 @@ export function buildShoutrrrUrl(config: NotificationConfig): string {
|
|||||||
return buildNtfyShoutrrrUrl(config);
|
return buildNtfyShoutrrrUrl(config);
|
||||||
case "pushover":
|
case "pushover":
|
||||||
return buildPushoverShoutrrrUrl(config);
|
return buildPushoverShoutrrrUrl(config);
|
||||||
|
case "telegram":
|
||||||
|
return buildTelegramShoutrrrUrl(config);
|
||||||
case "custom":
|
case "custom":
|
||||||
return buildCustomShoutrrrUrl(config);
|
return buildCustomShoutrrrUrl(config);
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
5
app/server/modules/notifications/builders/telegram.ts
Normal file
5
app/server/modules/notifications/builders/telegram.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
|
export function buildTelegramShoutrrrUrl(config: Extract<NotificationConfig, { type: "telegram" }>): string {
|
||||||
|
return `telegram://${config.botToken}@telegram?channels=${config.chatId}`;
|
||||||
|
}
|
||||||
@@ -65,6 +65,11 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
|
|||||||
...config,
|
...config,
|
||||||
apiToken: await cryptoUtils.encrypt(config.apiToken),
|
apiToken: await cryptoUtils.encrypt(config.apiToken),
|
||||||
};
|
};
|
||||||
|
case "telegram":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
botToken: await cryptoUtils.encrypt(config.botToken),
|
||||||
|
};
|
||||||
case "custom":
|
case "custom":
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
@@ -107,6 +112,11 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
|
|||||||
...config,
|
...config,
|
||||||
apiToken: await cryptoUtils.decrypt(config.apiToken),
|
apiToken: await cryptoUtils.decrypt(config.apiToken),
|
||||||
};
|
};
|
||||||
|
case "telegram":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
botToken: await cryptoUtils.decrypt(config.botToken),
|
||||||
|
};
|
||||||
case "custom":
|
case "custom":
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const repositoriesController = new Hono()
|
|||||||
short_id: snapshot.short_id,
|
short_id: snapshot.short_id,
|
||||||
duration,
|
duration,
|
||||||
paths: snapshot.paths,
|
paths: snapshot.paths,
|
||||||
|
tags: snapshot.tags ?? [],
|
||||||
size: summary?.total_bytes_processed || 0,
|
size: summary?.total_bytes_processed || 0,
|
||||||
time: new Date(snapshot.time).getTime(),
|
time: new Date(snapshot.time).getTime(),
|
||||||
};
|
};
|
||||||
@@ -113,6 +114,7 @@ export const repositoriesController = new Hono()
|
|||||||
time: new Date(snapshot.time).getTime(),
|
time: new Date(snapshot.time).getTime(),
|
||||||
paths: snapshot.paths,
|
paths: snapshot.paths,
|
||||||
size: snapshot.summary?.total_bytes_processed || 0,
|
size: snapshot.summary?.total_bytes_processed || 0,
|
||||||
|
tags: snapshot.tags ?? [],
|
||||||
summary: snapshot.summary,
|
summary: snapshot.summary,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryConfigSchema } from "~/schemas/restic";
|
import {
|
||||||
|
COMPRESSION_MODES,
|
||||||
|
OVERWRITE_MODES,
|
||||||
|
REPOSITORY_BACKENDS,
|
||||||
|
REPOSITORY_STATUS,
|
||||||
|
repositoryConfigSchema,
|
||||||
|
} from "~/schemas/restic";
|
||||||
|
|
||||||
export const repositorySchema = type({
|
export const repositorySchema = type({
|
||||||
id: "string",
|
id: "string",
|
||||||
@@ -168,6 +174,7 @@ export const snapshotSchema = type({
|
|||||||
paths: "string[]",
|
paths: "string[]",
|
||||||
size: "number",
|
size: "number",
|
||||||
duration: "number",
|
duration: "number",
|
||||||
|
tags: "string[]",
|
||||||
});
|
});
|
||||||
|
|
||||||
const listSnapshotsResponse = snapshotSchema.array();
|
const listSnapshotsResponse = snapshotSchema.array();
|
||||||
@@ -269,12 +276,16 @@ export const listSnapshotFilesDto = describeRoute({
|
|||||||
/**
|
/**
|
||||||
* Restore a snapshot
|
* Restore a snapshot
|
||||||
*/
|
*/
|
||||||
|
export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES);
|
||||||
|
|
||||||
export const restoreSnapshotBody = type({
|
export const restoreSnapshotBody = type({
|
||||||
snapshotId: "string",
|
snapshotId: "string",
|
||||||
include: "string[]?",
|
include: "string[]?",
|
||||||
exclude: "string[]?",
|
exclude: "string[]?",
|
||||||
excludeXattr: "string[]?",
|
excludeXattr: "string[]?",
|
||||||
delete: "boolean?",
|
delete: "boolean?",
|
||||||
|
targetPath: "string?",
|
||||||
|
overwrite: overwriteModeSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;
|
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { toMessage } from "../../utils/errors";
|
|||||||
import { generateShortId } from "../../utils/id";
|
import { generateShortId } from "../../utils/id";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
import { repoMutex } from "../../core/repository-mutex";
|
||||||
|
import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic";
|
||||||
|
|
||||||
const listRepositories = async () => {
|
const listRepositories = async () => {
|
||||||
const repositories = await db.query.repositoriesTable.findMany({});
|
const repositories = await db.query.repositoriesTable.findMany({});
|
||||||
@@ -160,15 +161,20 @@ const listSnapshots = async (name: string, backupId?: string) => {
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshots = [];
|
const releaseLock = await repoMutex.acquireShared(repository.id, "snapshots");
|
||||||
|
try {
|
||||||
|
let snapshots = [];
|
||||||
|
|
||||||
if (backupId) {
|
if (backupId) {
|
||||||
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
|
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
|
||||||
} else {
|
} else {
|
||||||
snapshots = await restic.snapshots(repository.config);
|
snapshots = await restic.snapshots(repository.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshots;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
|
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
|
||||||
@@ -180,28 +186,40 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await restic.ls(repository.config, snapshotId, path);
|
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
|
||||||
|
try {
|
||||||
|
const result = await restic.ls(repository.config, snapshotId, path);
|
||||||
|
|
||||||
if (!result.snapshot) {
|
if (!result.snapshot) {
|
||||||
throw new NotFoundError("Snapshot not found or empty");
|
throw new NotFoundError("Snapshot not found or empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
id: result.snapshot.id,
|
||||||
|
short_id: result.snapshot.short_id,
|
||||||
|
time: result.snapshot.time,
|
||||||
|
hostname: result.snapshot.hostname,
|
||||||
|
paths: result.snapshot.paths,
|
||||||
|
},
|
||||||
|
files: result.nodes,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
snapshot: {
|
|
||||||
id: result.snapshot.id,
|
|
||||||
short_id: result.snapshot.short_id,
|
|
||||||
time: result.snapshot.time,
|
|
||||||
hostname: result.snapshot.hostname,
|
|
||||||
paths: result.snapshot.paths,
|
|
||||||
},
|
|
||||||
files: result.nodes,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreSnapshot = async (
|
const restoreSnapshot = async (
|
||||||
name: string,
|
name: string,
|
||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean },
|
options?: {
|
||||||
|
include?: string[];
|
||||||
|
exclude?: string[];
|
||||||
|
excludeXattr?: string[];
|
||||||
|
delete?: boolean;
|
||||||
|
targetPath?: string;
|
||||||
|
overwrite?: OverwriteMode;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const repository = await db.query.repositoriesTable.findFirst({
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
where: eq(repositoriesTable.name, name),
|
where: eq(repositoriesTable.name, name),
|
||||||
@@ -211,14 +229,21 @@ const restoreSnapshot = async (
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await restic.restore(repository.config, snapshotId, "/", options);
|
const target = options?.targetPath || "/";
|
||||||
|
|
||||||
return {
|
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
|
||||||
success: true,
|
try {
|
||||||
message: "Snapshot restored successfully",
|
const result = await restic.restore(repository.config, snapshotId, target, options);
|
||||||
filesRestored: result.files_restored,
|
|
||||||
filesSkipped: result.files_skipped,
|
return {
|
||||||
};
|
success: true,
|
||||||
|
message: "Snapshot restored successfully",
|
||||||
|
filesRestored: result.files_restored,
|
||||||
|
filesSkipped: result.files_skipped,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSnapshotDetails = async (name: string, snapshotId: string) => {
|
const getSnapshotDetails = async (name: string, snapshotId: string) => {
|
||||||
@@ -230,14 +255,19 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshots = await restic.snapshots(repository.config);
|
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
|
||||||
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
|
try {
|
||||||
|
const snapshots = await restic.snapshots(repository.config);
|
||||||
|
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
throw new NotFoundError("Snapshot not found");
|
throw new NotFoundError("Snapshot not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshot;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkHealth = async (repositoryId: string) => {
|
const checkHealth = async (repositoryId: string) => {
|
||||||
@@ -249,18 +279,23 @@ const checkHealth = async (repositoryId: string) => {
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasErrors, error } = await restic.check(repository.config, { readData: true });
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
|
||||||
|
try {
|
||||||
|
const { hasErrors, error } = await restic.check(repository.config);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(repositoriesTable)
|
.update(repositoriesTable)
|
||||||
.set({
|
.set({
|
||||||
status: hasErrors ? "error" : "healthy",
|
status: hasErrors ? "error" : "healthy",
|
||||||
lastChecked: Date.now(),
|
lastChecked: Date.now(),
|
||||||
lastError: error,
|
lastError: error,
|
||||||
})
|
})
|
||||||
.where(eq(repositoriesTable.id, repository.id));
|
.where(eq(repositoriesTable.id, repository.id));
|
||||||
|
|
||||||
return { lastError: error };
|
return { lastError: error };
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doctorRepository = async (name: string) => {
|
const doctorRepository = async (name: string) => {
|
||||||
@@ -286,48 +321,51 @@ const doctorRepository = async (name: string) => {
|
|||||||
error: unlockResult.error,
|
error: unlockResult.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkResult = await restic.check(repository.config, { readData: false }).then(
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, "doctor");
|
||||||
(result) => result,
|
try {
|
||||||
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
const checkResult = await restic.check(repository.config, { readData: false }).then(
|
||||||
);
|
|
||||||
|
|
||||||
steps.push({
|
|
||||||
step: "check",
|
|
||||||
success: checkResult.success,
|
|
||||||
output: checkResult.output,
|
|
||||||
error: checkResult.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (checkResult.hasErrors) {
|
|
||||||
const repairResult = await restic.repairIndex(repository.config).then(
|
|
||||||
(result) => ({ success: true, output: result.output, error: null }),
|
|
||||||
(error) => ({ success: false, output: null, error: toMessage(error) }),
|
|
||||||
);
|
|
||||||
|
|
||||||
steps.push({
|
|
||||||
step: "repair_index",
|
|
||||||
success: repairResult.success,
|
|
||||||
output: repairResult.output,
|
|
||||||
error: repairResult.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
const recheckResult = await restic.check(repository.config, { readData: false }).then(
|
|
||||||
(result) => result,
|
(result) => result,
|
||||||
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||||
);
|
);
|
||||||
|
|
||||||
steps.push({
|
steps.push({
|
||||||
step: "recheck",
|
step: "check",
|
||||||
success: recheckResult.success,
|
success: checkResult.success,
|
||||||
output: recheckResult.output,
|
output: checkResult.output,
|
||||||
error: recheckResult.error,
|
error: checkResult.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (checkResult.hasErrors) {
|
||||||
|
const repairResult = await restic.repairIndex(repository.config).then(
|
||||||
|
(result) => ({ success: true, output: result.output, error: null }),
|
||||||
|
(error) => ({ success: false, output: null, error: toMessage(error) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "repair_index",
|
||||||
|
success: repairResult.success,
|
||||||
|
output: repairResult.output,
|
||||||
|
error: repairResult.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recheckResult = await restic.check(repository.config, { readData: false }).then(
|
||||||
|
(result) => result,
|
||||||
|
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "recheck",
|
||||||
|
success: recheckResult.success,
|
||||||
|
output: recheckResult.output,
|
||||||
|
error: recheckResult.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSuccessful = steps.every((s) => s.success);
|
const allSuccessful = steps.every((s) => s.success);
|
||||||
|
|
||||||
console.log("Doctor steps:", steps);
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(repositoriesTable)
|
.update(repositoriesTable)
|
||||||
.set({
|
.set({
|
||||||
@@ -352,7 +390,12 @@ const deleteSnapshot = async (name: string, snapshotId: string) => {
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await restic.deleteSnapshot(repository.config, snapshotId);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
|
||||||
|
try {
|
||||||
|
await restic.deleteSnapshot(repository.config, snapshotId);
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRepository = async (name: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
|
const updateRepository = async (name: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
|
||||||
|
|||||||
148
app/server/utils/backend-compatibility.ts
Normal file
148
app/server/utils/backend-compatibility.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type { RepositoryConfig } from "~/schemas/restic";
|
||||||
|
import { cryptoUtils } from "./crypto";
|
||||||
|
|
||||||
|
type BackendConflictGroup = "s3" | "gcs" | "azure" | "rest" | "sftp" | null;
|
||||||
|
|
||||||
|
export const getBackendConflictGroup = (backend: string): BackendConflictGroup => {
|
||||||
|
switch (backend) {
|
||||||
|
case "s3":
|
||||||
|
case "r2":
|
||||||
|
return "s3";
|
||||||
|
case "gcs":
|
||||||
|
return "gcs";
|
||||||
|
case "azure":
|
||||||
|
return "azure";
|
||||||
|
case "rest":
|
||||||
|
return "rest";
|
||||||
|
case "sftp":
|
||||||
|
return "sftp";
|
||||||
|
case "local":
|
||||||
|
case "rclone":
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasCompatibleCredentials = async (
|
||||||
|
config1: RepositoryConfig,
|
||||||
|
config2: RepositoryConfig,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const group1 = getBackendConflictGroup(config1.backend);
|
||||||
|
const group2 = getBackendConflictGroup(config2.backend);
|
||||||
|
|
||||||
|
if (!group1 || !group2 || group1 !== group2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (group1) {
|
||||||
|
case "s3": {
|
||||||
|
if (
|
||||||
|
(config1.backend === "s3" || config1.backend === "r2") &&
|
||||||
|
(config2.backend === "s3" || config2.backend === "r2")
|
||||||
|
) {
|
||||||
|
const accessKey1 = await cryptoUtils.decrypt(config1.accessKeyId);
|
||||||
|
const secretKey1 = await cryptoUtils.decrypt(config1.secretAccessKey);
|
||||||
|
|
||||||
|
const accessKey2 = await cryptoUtils.decrypt(config2.accessKeyId);
|
||||||
|
const secretKey2 = await cryptoUtils.decrypt(config2.secretAccessKey);
|
||||||
|
|
||||||
|
return accessKey1 === accessKey2 && secretKey1 === secretKey2;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
case "gcs": {
|
||||||
|
if (config1.backend === "gcs" && config2.backend === "gcs") {
|
||||||
|
const credentials1 = await cryptoUtils.decrypt(config1.credentialsJson);
|
||||||
|
const credentials2 = await cryptoUtils.decrypt(config2.credentialsJson);
|
||||||
|
|
||||||
|
return credentials1 === credentials2 && config1.projectId === config2.projectId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
case "azure": {
|
||||||
|
if (config1.backend === "azure" && config2.backend === "azure") {
|
||||||
|
const config1Accountkey = await cryptoUtils.decrypt(config1.accountKey);
|
||||||
|
const config2Accountkey = await cryptoUtils.decrypt(config2.accountKey);
|
||||||
|
|
||||||
|
return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
case "rest": {
|
||||||
|
if (config1.backend === "rest" && config2.backend === "rest") {
|
||||||
|
if (!config1.username && !config2.username && !config1.password && !config2.password) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config1Username = await cryptoUtils.decrypt(config1.username || "");
|
||||||
|
const config1Password = await cryptoUtils.decrypt(config1.password || "");
|
||||||
|
const config2Username = await cryptoUtils.decrypt(config2.username || "");
|
||||||
|
const config2Password = await cryptoUtils.decrypt(config2.password || "");
|
||||||
|
|
||||||
|
return config1Username === config2Username && config1Password === config2Password;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
case "sftp": {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CompatibilityResult {
|
||||||
|
repositoryId: string;
|
||||||
|
compatible: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkMirrorCompatibility = async (
|
||||||
|
primaryConfig: RepositoryConfig,
|
||||||
|
mirrorConfig: RepositoryConfig,
|
||||||
|
mirrorRepositoryId: string,
|
||||||
|
): Promise<CompatibilityResult> => {
|
||||||
|
const primaryConflictGroup = getBackendConflictGroup(primaryConfig.backend);
|
||||||
|
const mirrorConflictGroup = getBackendConflictGroup(mirrorConfig.backend);
|
||||||
|
|
||||||
|
if (!primaryConflictGroup || !mirrorConflictGroup) {
|
||||||
|
return {
|
||||||
|
repositoryId: mirrorRepositoryId,
|
||||||
|
compatible: true,
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryConflictGroup !== mirrorConflictGroup) {
|
||||||
|
return {
|
||||||
|
repositoryId: mirrorRepositoryId,
|
||||||
|
compatible: true,
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const compatible = await hasCompatibleCredentials(primaryConfig, mirrorConfig);
|
||||||
|
|
||||||
|
if (compatible) {
|
||||||
|
return {
|
||||||
|
repositoryId: mirrorRepositoryId,
|
||||||
|
compatible: true,
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repositoryId: mirrorRepositoryId,
|
||||||
|
compatible: false,
|
||||||
|
reason: `Both use ${primaryConflictGroup.toUpperCase()} backends with different credentials`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIncompatibleMirrorError = (mirrorRepoName: string, primaryBackend: string, mirrorBackend: string) => {
|
||||||
|
return (
|
||||||
|
`Cannot mirror to ${mirrorRepoName}: both repositories use the same backend type (${primaryBackend}/${mirrorBackend}) with different credentials. ` +
|
||||||
|
"Restic cannot use different credentials for the same backend in a copy operation. " +
|
||||||
|
"Consider creating a new backup scheduler with the desired destination instead."
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ 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";
|
import { safeSpawn } from "./spawn";
|
||||||
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
import type { CompressionMode, RepositoryConfig, OverwriteMode } from "~/schemas/restic";
|
||||||
import { ResticError } from "./errors";
|
import { ResticError } from "./errors";
|
||||||
|
|
||||||
const backupOutputSchema = type({
|
const backupOutputSchema = type({
|
||||||
@@ -40,6 +40,7 @@ const snapshotInfoSchema = type({
|
|||||||
time: "string",
|
time: "string",
|
||||||
uid: "number?",
|
uid: "number?",
|
||||||
username: "string",
|
username: "string",
|
||||||
|
tags: "string[]?",
|
||||||
summary: type({
|
summary: type({
|
||||||
backup_end: "string",
|
backup_end: "string",
|
||||||
backup_start: "string",
|
backup_start: "string",
|
||||||
@@ -200,8 +201,8 @@ const init = async (config: RepositoryConfig) => {
|
|||||||
|
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const args = ["init", "--repo", repoUrl, "--json"];
|
const args = ["init", "--repo", repoUrl];
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
@@ -277,8 +278,7 @@ const backup = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
args.push("--json");
|
|
||||||
|
|
||||||
const logData = throttle((data: string) => {
|
const logData = throttle((data: string) => {
|
||||||
logger.info(data.trim());
|
logger.info(data.trim());
|
||||||
@@ -353,7 +353,7 @@ const backup = async (
|
|||||||
|
|
||||||
const restoreOutputSchema = type({
|
const restoreOutputSchema = type({
|
||||||
message_type: "'summary'",
|
message_type: "'summary'",
|
||||||
total_files: "number",
|
total_files: "number?",
|
||||||
files_restored: "number",
|
files_restored: "number",
|
||||||
files_skipped: "number",
|
files_skipped: "number",
|
||||||
total_bytes: "number?",
|
total_bytes: "number?",
|
||||||
@@ -369,8 +369,8 @@ const restore = async (
|
|||||||
include?: string[];
|
include?: string[];
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
excludeXattr?: string[];
|
excludeXattr?: string[];
|
||||||
path?: string;
|
|
||||||
delete?: boolean;
|
delete?: boolean;
|
||||||
|
overwrite?: OverwriteMode;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
@@ -378,8 +378,8 @@ const restore = async (
|
|||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
|
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
|
||||||
|
|
||||||
if (options?.path) {
|
if (options?.overwrite) {
|
||||||
args[args.length - 4] = `${snapshotId}:${options.path}`;
|
args.push("--overwrite", options.overwrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.delete) {
|
if (options?.delete) {
|
||||||
@@ -404,9 +404,9 @@ const restore = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
args.push("--json");
|
|
||||||
|
|
||||||
|
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
|
|
||||||
@@ -467,8 +467,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
args.push("--json");
|
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
@@ -517,8 +516,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||||||
}
|
}
|
||||||
|
|
||||||
args.push("--prune");
|
args.push("--prune");
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
args.push("--json");
|
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
@@ -536,7 +534,7 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
|||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
@@ -580,13 +578,13 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--json", "--long"];
|
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
|
||||||
|
|
||||||
if (path) {
|
if (path) {
|
||||||
args.push(path);
|
args.push(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
|
|
||||||
const res = await safeSpawn({ command: "restic", args, env });
|
const res = await safeSpawn({ command: "restic", args, env });
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
@@ -636,8 +634,8 @@ const unlock = async (config: RepositoryConfig) => {
|
|||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
|
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
@@ -661,7 +659,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
|||||||
args.push("--read-data");
|
args.push("--read-data");
|
||||||
}
|
}
|
||||||
|
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
@@ -695,7 +693,7 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const args = ["repair", "index", "--repo", repoUrl];
|
const args = ["repair", "index", "--repo", repoUrl];
|
||||||
addRepoSpecificArgs(args, config, env);
|
addCommonArgs(args, config, env);
|
||||||
|
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
@@ -716,10 +714,65 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
|
const copy = async (
|
||||||
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
sourceConfig: RepositoryConfig,
|
||||||
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
destConfig: RepositoryConfig,
|
||||||
|
options: {
|
||||||
|
tag?: string;
|
||||||
|
snapshotId?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const sourceRepoUrl = buildRepoUrl(sourceConfig);
|
||||||
|
const destRepoUrl = buildRepoUrl(destConfig);
|
||||||
|
|
||||||
|
const sourceEnv = await buildEnv(sourceConfig);
|
||||||
|
const destEnv = await buildEnv(destConfig);
|
||||||
|
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
...sourceEnv,
|
||||||
|
...destEnv,
|
||||||
|
RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl];
|
||||||
|
|
||||||
|
if (options.tag) {
|
||||||
|
args.push("--tag", options.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.snapshotId) {
|
||||||
|
args.push(options.snapshotId);
|
||||||
|
} else {
|
||||||
|
args.push("latest");
|
||||||
|
}
|
||||||
|
|
||||||
|
addCommonArgs(args, destConfig, destEnv);
|
||||||
|
|
||||||
|
if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) {
|
||||||
|
args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`);
|
||||||
|
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
|
||||||
|
await cleanupTemporaryKeys(sourceConfig, sourceEnv);
|
||||||
|
await cleanupTemporaryKeys(destConfig, destEnv);
|
||||||
|
|
||||||
|
const stdout = res.text();
|
||||||
|
const stderr = res.stderr.toString();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic copy failed: ${stderr}`);
|
||||||
|
throw new ResticError(res.exitCode, stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: stdout,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
||||||
@@ -732,6 +785,13 @@ const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addCommonArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
|
||||||
|
args.push("--retry-lock", "1m", "--json");
|
||||||
|
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
||||||
|
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const restic = {
|
export const restic = {
|
||||||
ensurePassfile,
|
ensurePassfile,
|
||||||
init,
|
init,
|
||||||
@@ -744,4 +804,5 @@ export const restic = {
|
|||||||
ls,
|
ls,
|
||||||
check,
|
check,
|
||||||
repairIndex,
|
repairIndex,
|
||||||
|
copy,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
* This removes passwords and credentials from logs and error messages
|
* This removes passwords and credentials from logs and error messages
|
||||||
*/
|
*/
|
||||||
export const sanitizeSensitiveData = (text: string): string => {
|
export const sanitizeSensitiveData = (text: string): string => {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
|
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
|
||||||
|
|
||||||
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
|
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
|
||||||
|
|||||||
Reference in New Issue
Block a user