Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff38f0128 | ||
|
|
33e6f3773b | ||
|
|
a91dede086 | ||
|
|
9b46737852 | ||
|
|
999850dab8 | ||
|
|
dbd9ae2241 | ||
|
|
0287bca4bb | ||
|
|
9a9991eb9b | ||
|
|
03b898f84c | ||
|
|
6fbb11fefe | ||
|
|
3bf3b22b96 | ||
|
|
58708cf35d | ||
|
|
1d4e7100ab | ||
|
|
0dfe000148 | ||
|
|
7d9d3d5d3d | ||
|
|
8e90c4ace1 | ||
|
|
803eb1cd76 | ||
|
|
673827f9f3 | ||
|
|
4328607cc1 | ||
|
|
bedd325a60 | ||
|
|
b26a062648 | ||
|
|
d190d9c8cd | ||
|
|
f8363a6c71 |
28
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:
|
||||||
@@ -49,6 +49,8 @@ services:
|
|||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris # Set your timezone here
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
@@ -76,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:
|
||||||
@@ -85,6 +87,8 @@ services:
|
|||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
@@ -142,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:
|
||||||
@@ -151,6 +155,8 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/zerobyte:/var/lib/zerobyte
|
- /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
@@ -199,13 +205,15 @@ 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:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- - /var/lib/zerobyte:/var/lib/zerobyte
|
- - /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
@@ -228,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:
|
||||||
@@ -237,6 +245,8 @@ services:
|
|||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- - /var/lib/zerobyte:/var/lib/zerobyte
|
- - /var/lib/zerobyte:/var/lib/zerobyte
|
||||||
@@ -255,7 +265,7 @@ docker compose up -d
|
|||||||
Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
|
Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -v im-nfs:/path/in/container nginx:latest
|
docker run -v zb-abc12:/path/in/container nginx:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Or using Docker Compose:
|
Or using Docker Compose:
|
||||||
@@ -265,13 +275,13 @@ services:
|
|||||||
myservice:
|
myservice:
|
||||||
image: nginx:latest
|
image: nginx:latest
|
||||||
volumes:
|
volumes:
|
||||||
- im-nfs:/path/in/container
|
- zb-abc12:/path/in/container
|
||||||
volumes:
|
volumes:
|
||||||
im-nfs:
|
zb-abc12:
|
||||||
external: true
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
The volume name format is `im-<volume-name>` where `<volume-name>` is the name you assigned to the volume in Zerobyte. You can verify that the volume is available by running:
|
The volume name format is `zb-<short-id>` where `<short-id>` is the unique identifier shown on the volume's Docker tab in Zerobyte. This short ID remains stable even if you rename the volume. You can verify that the volume is available by running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker volume ls
|
docker volume ls
|
||||||
|
|||||||
@@ -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, updateScheduleNotifications, updateVolume } from '../sdk.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 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, 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, 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user
|
* Register a new user
|
||||||
@@ -442,6 +442,23 @@ export const getRepositoryOptions = (options: Options<GetRepositoryData>) => que
|
|||||||
queryKey: getRepositoryQueryKey(options)
|
queryKey: getRepositoryQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a repository's name or settings
|
||||||
|
*/
|
||||||
|
export const updateRepositoryMutation = (options?: Partial<Options<UpdateRepositoryData>>): UseMutationOptions<UpdateRepositoryResponse, DefaultError, Options<UpdateRepositoryData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<UpdateRepositoryResponse, DefaultError, Options<UpdateRepositoryData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await updateRepository({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey("listSnapshots", options);
|
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey("listSnapshots", 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, 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, 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';
|
||||||
|
|
||||||
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> & {
|
||||||
/**
|
/**
|
||||||
@@ -276,6 +276,20 @@ export const getRepository = <ThrowOnError extends boolean = false>(options: Opt
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a repository's name or settings
|
||||||
|
*/
|
||||||
|
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
|
||||||
|
url: '/api/v1/repositories/{name}',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all snapshots in a repository
|
* List all snapshots in a repository
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export type ListVolumesResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -279,6 +280,7 @@ export type CreateVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -422,6 +424,7 @@ export type GetVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -465,6 +468,7 @@ export type UpdateVolumeData = {
|
|||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
|
name?: string;
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -522,6 +526,7 @@ export type UpdateVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -704,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';
|
||||||
@@ -771,6 +776,7 @@ export type ListRepositoriesResponses = {
|
|||||||
lastChecked: number | null;
|
lastChecked: number | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -843,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;
|
||||||
@@ -918,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';
|
||||||
@@ -985,6 +991,7 @@ export type GetRepositoryResponses = {
|
|||||||
lastChecked: number | null;
|
lastChecked: number | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -993,6 +1000,110 @@ export type GetRepositoryResponses = {
|
|||||||
|
|
||||||
export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryResponses];
|
export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryResponses];
|
||||||
|
|
||||||
|
export type UpdateRepositoryData = {
|
||||||
|
body?: {
|
||||||
|
compressionMode?: 'auto' | 'max' | 'off';
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/repositories/{name}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateRepositoryErrors = {
|
||||||
|
/**
|
||||||
|
* Repository not found
|
||||||
|
*/
|
||||||
|
404: unknown;
|
||||||
|
/**
|
||||||
|
* Repository with this name already exists
|
||||||
|
*/
|
||||||
|
409: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateRepositoryResponses = {
|
||||||
|
/**
|
||||||
|
* Repository updated successfully
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses];
|
||||||
|
|
||||||
export type ListSnapshotsData = {
|
export type ListSnapshotsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
@@ -1113,6 +1224,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;
|
||||||
@@ -1181,10 +1294,10 @@ export type ListBackupSchedulesResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: 'error' | 'in_progress' | 'success' | 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';
|
||||||
@@ -1251,6 +1364,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
lastChecked: number | null;
|
lastChecked: number | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -1304,6 +1418,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -1351,7 +1466,7 @@ export type CreateBackupScheduleResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
|
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
@@ -1412,10 +1527,10 @@ export type GetBackupScheduleResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: 'error' | 'in_progress' | 'success' | 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';
|
||||||
@@ -1482,6 +1597,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
lastChecked: number | null;
|
lastChecked: number | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -1535,6 +1651,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -1583,7 +1700,7 @@ export type UpdateBackupScheduleResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
|
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
@@ -1624,10 +1741,10 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: 'error' | 'in_progress' | 'success' | 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';
|
||||||
@@ -1694,6 +1811,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
lastChecked: number | null;
|
lastChecked: number | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -1747,6 +1865,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortId: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -1846,6 +1965,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;
|
||||||
@@ -1888,7 +2011,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;
|
||||||
@@ -1930,6 +2053,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;
|
||||||
@@ -1972,7 +2099,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;
|
||||||
@@ -2003,6 +2130,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;
|
||||||
@@ -2045,7 +2176,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;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -2060,6 +2191,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;
|
||||||
@@ -2116,6 +2251,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;
|
||||||
@@ -2158,7 +2297,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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2219,6 +2358,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;
|
||||||
@@ -2261,7 +2404,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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2276,6 +2419,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;
|
||||||
@@ -2342,6 +2489,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;
|
||||||
@@ -2384,7 +2535,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>
|
||||||
@@ -237,8 +233,7 @@ export const CreateRepositoryForm = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Choose whether to use Zerobyte's master password or enter a custom password for the existing
|
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
|
||||||
repository.
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import type { BackendType } from "~/schemas/volumes";
|
|||||||
|
|
||||||
type VolumeIconProps = {
|
type VolumeIconProps = {
|
||||||
backend: BackendType;
|
backend: BackendType;
|
||||||
size?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getIconAndColor = (backend: BackendType) => {
|
const getIconAndColor = (backend: BackendType) => {
|
||||||
@@ -41,12 +40,12 @@ const getIconAndColor = (backend: BackendType) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
|
export const VolumeIcon = ({ backend }: VolumeIconProps) => {
|
||||||
const { icon: Icon, label } = getIconAndColor(backend);
|
const { icon: Icon, label } = getIconAndColor(backend);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
|
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
|
||||||
<Icon size={size} />
|
<Icon className="h-4 w-4" />
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -164,10 +164,20 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
{schedule.lastBackupStatus === "success" && "✓ Success"}
|
{schedule.lastBackupStatus === "success" && "✓ Success"}
|
||||||
{schedule.lastBackupStatus === "error" && "✗ Error"}
|
{schedule.lastBackupStatus === "error" && "✗ Error"}
|
||||||
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
|
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
|
||||||
|
{schedule.lastBackupStatus === "warning" && "! Warning"}
|
||||||
{!schedule.lastBackupStatus && "—"}
|
{!schedule.lastBackupStatus && "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{schedule.lastBackupStatus === "warning" && (
|
||||||
|
<div className="md:col-span-2 lg:col-span-4">
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Warning Details</p>
|
||||||
|
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap break-all">
|
||||||
|
Last backup completed with warnings. Check your container logs for more details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{schedule.lastBackupError && (
|
{schedule.lastBackupError && (
|
||||||
<div className="md:col-span-2 lg:col-span-4">
|
<div className="md:col-span-2 lg:col-span-4">
|
||||||
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
|
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
|
||||||
|
|||||||
@@ -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] || "/";
|
||||||
|
|
||||||
@@ -67,7 +46,7 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
|
|
||||||
const addBasePath = useCallback(
|
const addBasePath = useCallback(
|
||||||
(displayPath: string): string => {
|
(displayPath: string): string => {
|
||||||
let vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
||||||
|
|
||||||
if (!vbp) return displayPath;
|
if (!vbp) return displayPath;
|
||||||
if (displayPath === "/") return vbp;
|
if (displayPath === "/") return vbp;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,8 +70,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 {
|
||||||
@@ -240,7 +238,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
@@ -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>
|
||||||
@@ -158,7 +157,10 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
|
|||||||
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
|
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
|
||||||
<TableCell className="capitalize">{notification.type}</TableCell>
|
<TableCell className="capitalize">{notification.type}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<StatusDot variant={notification.enabled ? "success" : "neutral"} label={notification.enabled ? "Enabled" : "Disabled"} />
|
<StatusDot
|
||||||
|
variant={notification.enabled ? "success" : "neutral"}
|
||||||
|
label={notification.enabled ? "Enabled" : "Disabled"}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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,141 +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
@@ -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 * 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>
|
|
||||||
{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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ 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: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -142,7 +140,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
{volume.status[0].toUpperCase() + volume.status.slice(1)}
|
{volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
<VolumeIcon backend={volume?.config.backend} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ export const DockerTabContent = ({ volume }: Props) => {
|
|||||||
services: {
|
services: {
|
||||||
nginx: {
|
nginx: {
|
||||||
image: "nginx:latest",
|
image: "nginx:latest",
|
||||||
volumes: [`im-${volume.name}:/path/in/container`],
|
volumes: [`zb-${volume.shortId}:/path/in/container`],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
volumes: {
|
volumes: {
|
||||||
[`im-${volume.name}`]: {
|
[`zb-${volume.shortId}`]: {
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`;
|
const dockerRunCommand = `docker run -v zb-${volume.shortId}:/path/in/container nginx:latest`;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: containersData,
|
data: containersData,
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
7
app/drizzle/0012_add_short_ids.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE `repositories_table` ADD `short_id` text;--> statement-breakpoint
|
||||||
|
UPDATE `repositories_table` SET `short_id` = lower(hex(randomblob(3))) WHERE `short_id` IS NULL;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint
|
||||||
|
|
||||||
|
ALTER TABLE `volumes_table` ADD `short_id` text;--> statement-breakpoint
|
||||||
|
UPDATE `volumes_table` SET `short_id` = lower(hex(randomblob(3))) WHERE `short_id` IS NULL;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);
|
||||||
6
app/drizzle/0013_elite_sprite.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE `app_metadata` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
40
app/drizzle/0014_wild_echo.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_repositories_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`short_id` text,
|
||||||
|
`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()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) 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
|
||||||
|
PRAGMA foreign_keys=ON;--> 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_volumes_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`short_id` text,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'unmounted' NOT NULL,
|
||||||
|
`last_error` text,
|
||||||
|
`last_health_check` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) 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`);
|
||||||
40
app/drizzle/0015_jazzy_sersi.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;--> 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()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) 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
|
||||||
|
PRAGMA foreign_keys=ON;--> 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_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()) NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) 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`);
|
||||||
47
app/drizzle/0016_fix-timestamps-to-ms.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- Convert timestamps from seconds to milliseconds (multiply by 1000)
|
||||||
|
-- Only convert values that appear to be in seconds (less than year 2100 threshold)
|
||||||
|
|
||||||
|
UPDATE `volumes_table` SET `last_health_check` = `last_health_check` * 1000 WHERE `last_health_check` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `volumes_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `volumes_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
UPDATE `users_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `users_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
UPDATE `sessions_table` SET `expires_at` = `expires_at` * 1000 WHERE `expires_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `sessions_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
UPDATE `repositories_table` SET `last_checked` = `last_checked` * 1000 WHERE `last_checked` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `repositories_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `repositories_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
UPDATE `backup_schedules_table` SET `last_backup_at` = `last_backup_at` * 1000 WHERE `last_backup_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `backup_schedules_table` SET `next_backup_at` = `next_backup_at` * 1000 WHERE `next_backup_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `backup_schedules_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `backup_schedules_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
UPDATE `notification_destinations_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `notification_destinations_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
UPDATE `backup_schedule_notifications_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
UPDATE `app_metadata` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `app_metadata` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
|
||||||
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');
|
||||||
613
app/drizzle/meta/0012_snapshot.json
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "bbca8451-3894-4556-9824-c309b5105628",
|
||||||
|
"prevId": "67552135-fa49-478f-9333-107d3dbd7610",
|
||||||
|
"tables": {
|
||||||
|
"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",
|
||||||
|
"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())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "volumes_table",
|
||||||
|
"columnsFrom": ["volume_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "repositories_table",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"has_downloaded_restic_password": {
|
||||||
|
"name": "has_downloaded_restic_password",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_table_username_unique": {
|
||||||
|
"name": "users_table_username_unique",
|
||||||
|
"columns": ["username"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"volumes_table": {
|
||||||
|
"name": "volumes_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
653
app/drizzle/meta/0013_snapshot.json
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "794bddf6-1978-46e4-88d5-051d76cfa2f6",
|
||||||
|
"prevId": "bbca8451-3894-4556-9824-c309b5105628",
|
||||||
|
"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",
|
||||||
|
"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())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "volumes_table",
|
||||||
|
"columnsFrom": ["volume_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "repositories_table",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"has_downloaded_restic_password": {
|
||||||
|
"name": "has_downloaded_restic_password",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_table_username_unique": {
|
||||||
|
"name": "users_table_username_unique",
|
||||||
|
"columns": ["username"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"volumes_table": {
|
||||||
|
"name": "volumes_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
653
app/drizzle/meta/0014_snapshot.json
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "05309ea5-8ef2-4d63-b3d2-9842b2b4111b",
|
||||||
|
"prevId": "794bddf6-1978-46e4-88d5-051d76cfa2f6",
|
||||||
|
"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",
|
||||||
|
"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())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "volumes_table",
|
||||||
|
"columnsFrom": ["volume_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "repositories_table",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"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": false,
|
||||||
|
"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",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"has_downloaded_restic_password": {
|
||||||
|
"name": "has_downloaded_restic_password",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_table_username_unique": {
|
||||||
|
"name": "users_table_username_unique",
|
||||||
|
"columns": ["username"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"volumes_table": {
|
||||||
|
"name": "volumes_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"short_id": {
|
||||||
|
"name": "short_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"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": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
653
app/drizzle/meta/0015_snapshot.json
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "e52fe10a-3f36-4b21-abef-c15990d28363",
|
||||||
|
"prevId": "05309ea5-8ef2-4d63-b3d2-9842b2b4111b",
|
||||||
|
"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",
|
||||||
|
"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())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "volumes_table",
|
||||||
|
"columnsFrom": ["volume_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "repositories_table",
|
||||||
|
"columnsFrom": ["repository_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"has_downloaded_restic_password": {
|
||||||
|
"name": "has_downloaded_restic_password",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_table_username_unique": {
|
||||||
|
"name": "users_table_username_unique",
|
||||||
|
"columns": ["username"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"volumes_table": {
|
||||||
|
"name": "volumes_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
653
app/drizzle/meta/0016_snapshot.json
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
{
|
||||||
|
"id": "e50ff0fb-4111-4d20-b550-9407ee397517",
|
||||||
|
"prevId": "e52fe10a-3f36-4b21-abef-c15990d28363",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,90 +1,132 @@
|
|||||||
{
|
{
|
||||||
"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,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764100562084,
|
||||||
|
"tag": "0012_add_short_ids",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764182159797,
|
||||||
|
"tag": "0013_elite_sprite",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764182405089,
|
||||||
|
"tag": "0014_wild_echo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764182465287,
|
||||||
|
"tag": "0015_jazzy_sersi",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764194697035,
|
||||||
|
"tag": "0016_fix-timestamps-to-ms",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764357897219,
|
||||||
|
"tag": "0017_fix-compression-modes",
|
||||||
|
"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;
|
||||||
@@ -83,6 +91,7 @@ export const NOTIFICATION_EVENTS = {
|
|||||||
start: "start",
|
start: "start",
|
||||||
success: "success",
|
success: "success",
|
||||||
failure: "failure",
|
failure: "failure",
|
||||||
|
warning: "warning",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
|
|||||||
export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
|
export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
|
||||||
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
|
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
|
||||||
export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock";
|
export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock";
|
||||||
|
|
||||||
|
export const REQUIRED_MIGRATIONS = ["v0.14.0"];
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface ServerEvents {
|
|||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error" | "stopped";
|
status: "success" | "error" | "stopped" | "warning";
|
||||||
}) => void;
|
}) => void;
|
||||||
"volume:mounted": (data: { volumeName: string }) => void;
|
"volume:mounted": (data: { volumeName: string }) => void;
|
||||||
"volume:unmounted": (data: { volumeName: string }) => void;
|
"volume:unmounted": (data: { volumeName: string }) => void;
|
||||||
|
|||||||
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();
|
||||||
@@ -10,8 +10,6 @@ import fs from "node:fs/promises";
|
|||||||
await fs.mkdir(path.dirname(DATABASE_URL), { recursive: true });
|
await fs.mkdir(path.dirname(DATABASE_URL), { recursive: true });
|
||||||
|
|
||||||
const sqlite = new Database(DATABASE_URL);
|
const sqlite = new Database(DATABASE_URL);
|
||||||
sqlite.run("PRAGMA foreign_keys = ON;");
|
|
||||||
|
|
||||||
export const db = drizzle({ client: sqlite, schema });
|
export const db = drizzle({ client: sqlite, schema });
|
||||||
|
|
||||||
export const runDbMigrations = () => {
|
export const runDbMigrations = () => {
|
||||||
@@ -23,4 +21,6 @@ export const runDbMigrations = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
migrate(db, { migrationsFolder });
|
migrate(db, { migrationsFolder });
|
||||||
|
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON;");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import type { NotificationType, notificationConfigSchema } from "~/schemas/notif
|
|||||||
*/
|
*/
|
||||||
export const volumesTable = sqliteTable("volumes_table", {
|
export const volumesTable = sqliteTable("volumes_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
|
shortId: text("short_id").notNull().unique(),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull().unique(),
|
||||||
type: text().$type<BackendType>().notNull(),
|
type: text().$type<BackendType>().notNull(),
|
||||||
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
||||||
lastError: text("last_error"),
|
lastError: text("last_error"),
|
||||||
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
||||||
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
||||||
});
|
});
|
||||||
@@ -29,8 +30,8 @@ export const usersTable = sqliteTable("users_table", {
|
|||||||
username: text().notNull().unique(),
|
username: text().notNull().unique(),
|
||||||
passwordHash: text("password_hash").notNull(),
|
passwordHash: text("password_hash").notNull(),
|
||||||
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
export type User = typeof usersTable.$inferSelect;
|
export type User = typeof usersTable.$inferSelect;
|
||||||
export const sessionsTable = sqliteTable("sessions_table", {
|
export const sessionsTable = sqliteTable("sessions_table", {
|
||||||
@@ -39,7 +40,7 @@ export const sessionsTable = sqliteTable("sessions_table", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||||
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
export type Session = typeof sessionsTable.$inferSelect;
|
export type Session = typeof sessionsTable.$inferSelect;
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export type Session = typeof sessionsTable.$inferSelect;
|
|||||||
*/
|
*/
|
||||||
export const repositoriesTable = sqliteTable("repositories_table", {
|
export const repositoriesTable = sqliteTable("repositories_table", {
|
||||||
id: text().primaryKey(),
|
id: text().primaryKey(),
|
||||||
|
shortId: text("short_id").notNull().unique(),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull().unique(),
|
||||||
type: text().$type<RepositoryBackend>().notNull(),
|
type: text().$type<RepositoryBackend>().notNull(),
|
||||||
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
|
||||||
@@ -55,8 +57,8 @@ export const repositoriesTable = sqliteTable("repositories_table", {
|
|||||||
status: text().$type<RepositoryStatus>().default("unknown"),
|
status: text().$type<RepositoryStatus>().default("unknown"),
|
||||||
lastChecked: int("last_checked", { mode: "number" }),
|
lastChecked: int("last_checked", { mode: "number" }),
|
||||||
lastError: text("last_error"),
|
lastError: text("last_error"),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
export type Repository = typeof repositoriesTable.$inferSelect;
|
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||||
|
|
||||||
@@ -85,11 +87,11 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||||||
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||||
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
|
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||||
lastBackupAt: int("last_backup_at", { mode: "number" }),
|
lastBackupAt: int("last_backup_at", { mode: "number" }),
|
||||||
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress">(),
|
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning">(),
|
||||||
lastBackupError: text("last_backup_error"),
|
lastBackupError: text("last_backup_error"),
|
||||||
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
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, {
|
||||||
@@ -113,8 +115,8 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
|
|||||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
type: text().$type<NotificationType>().notNull(),
|
type: text().$type<NotificationType>().notNull(),
|
||||||
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
|
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
|
||||||
schedules: many(backupScheduleNotificationsTable),
|
schedules: many(backupScheduleNotificationsTable),
|
||||||
@@ -136,7 +138,7 @@ export const backupScheduleNotificationsTable = sqliteTable(
|
|||||||
notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false),
|
notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false),
|
||||||
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
|
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
|
||||||
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
|
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
},
|
},
|
||||||
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
|
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
|
||||||
);
|
);
|
||||||
@@ -151,3 +153,15 @@ export const backupScheduleNotificationRelations = relations(backupScheduleNotif
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
|
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Metadata Table
|
||||||
|
* Used for storing key-value pairs like migration checkpoints
|
||||||
|
*/
|
||||||
|
export const appMetadataTable = sqliteTable("app_metadata", {
|
||||||
|
key: text().primaryKey(),
|
||||||
|
value: text().notNull(),
|
||||||
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
||||||
|
});
|
||||||
|
export type AppMetadata = typeof appMetadataTable.$inferSelect;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { authController } from "./modules/auth/auth.controller";
|
|||||||
import { requireAuth } from "./modules/auth/auth.middleware";
|
import { requireAuth } from "./modules/auth/auth.middleware";
|
||||||
import { driverController } from "./modules/driver/driver.controller";
|
import { driverController } from "./modules/driver/driver.controller";
|
||||||
import { startup } from "./modules/lifecycle/startup";
|
import { startup } from "./modules/lifecycle/startup";
|
||||||
|
import { migrateToShortIds } from "./modules/lifecycle/migration";
|
||||||
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
||||||
import { systemController } from "./modules/system/system.controller";
|
import { systemController } from "./modules/system/system.controller";
|
||||||
import { volumeController } from "./modules/volumes/volume.controller";
|
import { volumeController } from "./modules/volumes/volume.controller";
|
||||||
@@ -19,7 +20,8 @@ import { notificationsController } from "./modules/notifications/notifications.c
|
|||||||
import { handleServiceError } from "./utils/errors";
|
import { handleServiceError } from "./utils/errors";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
import { shutdown } from "./modules/lifecycle/shutdown";
|
import { shutdown } from "./modules/lifecycle/shutdown";
|
||||||
import { SOCKET_PATH } from "./core/constants";
|
import { REQUIRED_MIGRATIONS, SOCKET_PATH } from "./core/constants";
|
||||||
|
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
|
||||||
|
|
||||||
export const generalDescriptor = (app: Hono) =>
|
export const generalDescriptor = (app: Hono) =>
|
||||||
openAPIRouteHandler(app, {
|
openAPIRouteHandler(app, {
|
||||||
@@ -68,6 +70,9 @@ app.onError((err, c) => {
|
|||||||
|
|
||||||
runDbMigrations();
|
runDbMigrations();
|
||||||
|
|
||||||
|
await migrateToShortIds();
|
||||||
|
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
|
||||||
|
|
||||||
const { docker } = await getCapabilities();
|
const { docker } = await getCapabilities();
|
||||||
|
|
||||||
if (docker) {
|
if (docker) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db } from "../../db/db";
|
|||||||
import { sessionsTable, usersTable } from "../../db/schema";
|
import { sessionsTable, usersTable } from "../../db/schema";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
|
|
||||||
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
|
const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +30,7 @@ export class AuthService {
|
|||||||
|
|
||||||
logger.info(`User registered: ${username}`);
|
logger.info(`User registered: ${username}`);
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
|
const expiresAt = Date.now() + SESSION_DURATION;
|
||||||
|
|
||||||
await db.insert(sessionsTable).values({
|
await db.insert(sessionsTable).values({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
@@ -66,7 +66,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
|
const expiresAt = Date.now() + SESSION_DURATION;
|
||||||
|
|
||||||
await db.insert(sessionsTable).values({
|
await db.insert(sessionsTable).values({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const backupScheduleSchema = type({
|
|||||||
excludePatterns: "string[] | null",
|
excludePatterns: "string[] | null",
|
||||||
includePatterns: "string[] | null",
|
includePatterns: "string[] | null",
|
||||||
lastBackupAt: "number | null",
|
lastBackupAt: "number | null",
|
||||||
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
|
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
|
||||||
lastBackupError: "string | null",
|
lastBackupError: "string | null",
|
||||||
nextBackupAt: "number | null",
|
nextBackupAt: "number | null",
|
||||||
createdAt: "number",
|
createdAt: "number",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backu
|
|||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
import { notificationsService } from "../notifications/notifications.service";
|
import { notificationsService } from "../notifications/notifications.service";
|
||||||
|
import { repoMutex } from "../../core/repository-mutex";
|
||||||
|
|
||||||
const runningBackups = new Map<number, AbortController>();
|
const runningBackups = new Map<number, AbortController>();
|
||||||
|
|
||||||
@@ -209,7 +210,12 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
|
|
||||||
await db
|
await db
|
||||||
.update(backupSchedulesTable)
|
.update(backupSchedulesTable)
|
||||||
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null, nextBackupAt })
|
.set({
|
||||||
|
lastBackupStatus: "in_progress",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastBackupError: null,
|
||||||
|
nextBackupAt,
|
||||||
|
})
|
||||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -236,21 +242,28 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
backupOptions.include = schedule.includePatterns;
|
backupOptions.include = schedule.includePatterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
||||||
@@ -258,24 +271,28 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
.update(backupSchedulesTable)
|
.update(backupSchedulesTable)
|
||||||
.set({
|
.set({
|
||||||
lastBackupAt: Date.now(),
|
lastBackupAt: Date.now(),
|
||||||
lastBackupStatus: "success",
|
lastBackupStatus: exitCode === 0 ? "success" : "warning",
|
||||||
lastBackupError: null,
|
lastBackupError: null,
|
||||||
nextBackupAt: nextBackupAt,
|
nextBackupAt: nextBackupAt,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
|
if (exitCode !== 0) {
|
||||||
|
logger.warn(`Backup completed with warnings for volume ${volume.name} to repository ${repository.name}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
serverEvents.emit("backup:completed", {
|
serverEvents.emit("backup:completed", {
|
||||||
scheduleId,
|
scheduleId,
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
status: "success",
|
status: exitCode === 0 ? "success" : "warning",
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationsService
|
notificationsService
|
||||||
.sendBackupNotification(scheduleId, "success", {
|
.sendBackupNotification(scheduleId, exitCode === 0 ? "success" : "warning", {
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
})
|
})
|
||||||
@@ -393,8 +410,14 @@ 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}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { volumeService } from "../volumes/volume.service";
|
import { volumeService } from "../volumes/volume.service";
|
||||||
import { getVolumePath } from "../volumes/helpers";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../../db/db";
|
||||||
|
import { volumesTable } from "../../db/schema";
|
||||||
|
|
||||||
export const driverController = new Hono()
|
export const driverController = new Hono()
|
||||||
.post("/VolumeDriver.Capabilities", (c) => {
|
.post("/VolumeDriver.Capabilities", (c) => {
|
||||||
@@ -30,10 +33,18 @@ export const driverController = new Hono()
|
|||||||
return c.json({ Err: "Volume name is required" }, 400);
|
return c.json({ Err: "Volume name is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumeName = body.Name.replace(/^zb-/, "");
|
const shortId = body.Name.replace(/^zb-/, "");
|
||||||
|
|
||||||
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
|
where: eq(volumesTable.shortId, shortId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!volume) {
|
||||||
|
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: getVolumePath(volumeName),
|
Mountpoint: getVolumePath(volume),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post("/VolumeDriver.Unmount", (c) => {
|
.post("/VolumeDriver.Unmount", (c) => {
|
||||||
@@ -48,7 +59,15 @@ export const driverController = new Hono()
|
|||||||
return c.json({ Err: "Volume name is required" }, 400);
|
return c.json({ Err: "Volume name is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
|
const shortId = body.Name.replace(/^zb-/, "");
|
||||||
|
|
||||||
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
|
where: eq(volumesTable.shortId, shortId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!volume) {
|
||||||
|
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: getVolumePath(volume),
|
Mountpoint: getVolumePath(volume),
|
||||||
@@ -61,11 +80,19 @@ export const driverController = new Hono()
|
|||||||
return c.json({ Err: "Volume name is required" }, 400);
|
return c.json({ Err: "Volume name is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
|
const shortId = body.Name.replace(/^zb-/, "");
|
||||||
|
|
||||||
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
|
where: eq(volumesTable.shortId, shortId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!volume) {
|
||||||
|
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Volume: {
|
Volume: {
|
||||||
Name: `zb-${volume.name}`,
|
Name: `zb-${volume.shortId}`,
|
||||||
Mountpoint: getVolumePath(volume),
|
Mountpoint: getVolumePath(volume),
|
||||||
Status: {},
|
Status: {},
|
||||||
},
|
},
|
||||||
@@ -76,7 +103,7 @@ export const driverController = new Hono()
|
|||||||
const volumes = await volumeService.listVolumes();
|
const volumes = await volumeService.listVolumes();
|
||||||
|
|
||||||
const res = volumes.map((volume) => ({
|
const res = volumes.map((volume) => ({
|
||||||
Name: `zb-${volume.name}`,
|
Name: `zb-${volume.shortId}`,
|
||||||
Mountpoint: getVolumePath(volume),
|
Mountpoint: getVolumePath(volume),
|
||||||
Status: {},
|
Status: {},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const eventsController = new Hono().get("/", (c) => {
|
|||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error" | "stopped";
|
status: "success" | "error" | "stopped" | "warning";
|
||||||
}) => {
|
}) => {
|
||||||
stream.writeSSE({
|
stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
|
|||||||
88
app/server/modules/lifecycle/checkpoint.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { db } from "../../db/db";
|
||||||
|
import { appMetadataTable, usersTable } from "../../db/schema";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
|
||||||
|
const MIGRATION_KEY_PREFIX = "migration:";
|
||||||
|
|
||||||
|
export const recordMigrationCheckpoint = async (version: string): Promise<void> => {
|
||||||
|
const key = `${MIGRATION_KEY_PREFIX}${version}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(appMetadataTable)
|
||||||
|
.values({
|
||||||
|
key,
|
||||||
|
value: JSON.stringify({ completedAt: new Date().toISOString() }),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: appMetadataTable.key,
|
||||||
|
set: {
|
||||||
|
value: JSON.stringify({ completedAt: new Date().toISOString() }),
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Recorded migration checkpoint for ${version}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasMigrationCheckpoint = async (version: string): Promise<boolean> => {
|
||||||
|
const key = `${MIGRATION_KEY_PREFIX}${version}`;
|
||||||
|
const result = await db.query.appMetadataTable.findFirst({
|
||||||
|
where: eq(appMetadataTable.key, key),
|
||||||
|
});
|
||||||
|
return result !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateRequiredMigrations = async (requiredVersions: string[]): Promise<void> => {
|
||||||
|
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
|
||||||
|
const isFreshInstall = userCount[0]?.count === 0;
|
||||||
|
|
||||||
|
if (isFreshInstall) {
|
||||||
|
logger.info("Fresh installation detected, skipping migration checkpoint validation.");
|
||||||
|
|
||||||
|
for (const version of requiredVersions) {
|
||||||
|
const hasCheckpoint = await hasMigrationCheckpoint(version);
|
||||||
|
if (!hasCheckpoint) {
|
||||||
|
await recordMigrationCheckpoint(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const version of requiredVersions) {
|
||||||
|
const hasCheckpoint = await hasMigrationCheckpoint(version);
|
||||||
|
if (!hasCheckpoint) {
|
||||||
|
logger.error(`
|
||||||
|
================================================================================
|
||||||
|
MIGRATION ERROR: Required migration ${version} has not been run.
|
||||||
|
|
||||||
|
You are attempting to start a version of Zerobyte that requires migration
|
||||||
|
checkpoints from previous versions. This typically happens when you skip
|
||||||
|
versions during an upgrade.
|
||||||
|
|
||||||
|
To fix this:
|
||||||
|
1. First upgrade to version ${version} and run the application once
|
||||||
|
2. Validate that everything is still working correctly
|
||||||
|
3. Then upgrade to the current version
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMigrationCheckpoints = async (): Promise<{ version: string; completedAt: string }[]> => {
|
||||||
|
const results = await db.query.appMetadataTable.findMany({
|
||||||
|
where: (table, { like }) => like(table.key, `${MIGRATION_KEY_PREFIX}%`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return results.map((r) => ({
|
||||||
|
version: r.key.replace(MIGRATION_KEY_PREFIX, ""),
|
||||||
|
completedAt: JSON.parse(r.value).completedAt,
|
||||||
|
}));
|
||||||
|
};
|
||||||
198
app/server/modules/lifecycle/migration.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../../db/db";
|
||||||
|
import { repositoriesTable } from "../../db/schema";
|
||||||
|
import { VOLUME_MOUNT_BASE, REPOSITORY_BASE } from "../../core/constants";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
|
||||||
|
import type { RepositoryConfig } from "~/schemas/restic";
|
||||||
|
|
||||||
|
const MIGRATION_VERSION = "v0.14.0";
|
||||||
|
|
||||||
|
interface MigrationResult {
|
||||||
|
success: boolean;
|
||||||
|
errors: Array<{ name: string; error: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MigrationError extends Error {
|
||||||
|
version: string;
|
||||||
|
failedItems: Array<{ name: string; error: string }>;
|
||||||
|
|
||||||
|
constructor(version: string, failedItems: Array<{ name: string; error: string }>) {
|
||||||
|
const itemNames = failedItems.map((e) => e.name).join(", ");
|
||||||
|
super(`Migration ${version} failed for: ${itemNames}`);
|
||||||
|
this.version = version;
|
||||||
|
this.failedItems = failedItems;
|
||||||
|
this.name = "MigrationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const migrateToShortIds = async () => {
|
||||||
|
const alreadyMigrated = await hasMigrationCheckpoint(MIGRATION_VERSION);
|
||||||
|
if (alreadyMigrated) {
|
||||||
|
logger.debug(`Migration ${MIGRATION_VERSION} already completed, skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Starting short ID migration (${MIGRATION_VERSION})...`);
|
||||||
|
|
||||||
|
const volumeResult = await migrateVolumeFolders();
|
||||||
|
const repoResult = await migrateRepositoryFolders();
|
||||||
|
|
||||||
|
const allErrors = [...volumeResult.errors, ...repoResult.errors];
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
for (const err of allErrors) {
|
||||||
|
logger.error(`Migration failure - ${err.name}: ${err.error}`);
|
||||||
|
}
|
||||||
|
throw new MigrationError(MIGRATION_VERSION, allErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordMigrationCheckpoint(MIGRATION_VERSION);
|
||||||
|
|
||||||
|
logger.info(`Short ID migration (${MIGRATION_VERSION}) complete.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrateVolumeFolders = async (): Promise<MigrationResult> => {
|
||||||
|
const errors: Array<{ name: string; error: string }> = [];
|
||||||
|
const volumes = await db.query.volumesTable.findMany({});
|
||||||
|
|
||||||
|
for (const volume of volumes) {
|
||||||
|
if (volume.config.backend === "directory") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPath = path.join(VOLUME_MOUNT_BASE, volume.name);
|
||||||
|
const newPath = path.join(VOLUME_MOUNT_BASE, volume.shortId);
|
||||||
|
|
||||||
|
const oldExists = await pathExists(oldPath);
|
||||||
|
const newExists = await pathExists(newPath);
|
||||||
|
|
||||||
|
if (oldExists && !newExists) {
|
||||||
|
try {
|
||||||
|
logger.info(`Migrating volume folder: ${oldPath} -> ${newPath}`);
|
||||||
|
await fs.rename(oldPath, newPath);
|
||||||
|
logger.info(`Successfully migrated volume folder for "${volume.name}"`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
errors.push({ name: `volume:${volume.name}`, error: errorMessage });
|
||||||
|
}
|
||||||
|
} else if (oldExists && newExists) {
|
||||||
|
logger.warn(
|
||||||
|
`Both old (${oldPath}) and new (${newPath}) paths exist for volume "${volume.name}". Manual intervention may be required.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: errors.length === 0, errors };
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrateRepositoryFolders = async (): Promise<MigrationResult> => {
|
||||||
|
const errors: Array<{ name: string; error: string }> = [];
|
||||||
|
const repositories = await db.query.repositoriesTable.findMany({});
|
||||||
|
|
||||||
|
for (const repo of repositories) {
|
||||||
|
if (repo.config.backend !== "local") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = repo.config as Extract<RepositoryConfig, { backend: "local" }>;
|
||||||
|
|
||||||
|
if (config.isExistingRepository) {
|
||||||
|
logger.debug(`Skipping imported repository "${repo.name}" - folder path is user-defined`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.name === repo.shortId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = config.path || REPOSITORY_BASE;
|
||||||
|
const oldPath = path.join(basePath, config.name);
|
||||||
|
const newPath = path.join(basePath, repo.shortId);
|
||||||
|
|
||||||
|
const oldExists = await pathExists(oldPath);
|
||||||
|
const newExists = await pathExists(newPath);
|
||||||
|
|
||||||
|
if (oldExists && !newExists) {
|
||||||
|
try {
|
||||||
|
logger.info(`Migrating repository folder: ${oldPath} -> ${newPath}`);
|
||||||
|
await fs.rename(oldPath, newPath);
|
||||||
|
|
||||||
|
const updatedConfig: RepositoryConfig = {
|
||||||
|
...config,
|
||||||
|
name: repo.shortId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(repositoriesTable)
|
||||||
|
.set({
|
||||||
|
config: updatedConfig,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(repositoriesTable.id, repo.id));
|
||||||
|
|
||||||
|
logger.info(`Successfully migrated repository folder and config for "${repo.name}"`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
|
||||||
|
}
|
||||||
|
} else if (oldExists && newExists) {
|
||||||
|
logger.warn(
|
||||||
|
`Both old (${oldPath}) and new (${newPath}) paths exist for repository "${repo.name}". Manual intervention may be required.`,
|
||||||
|
);
|
||||||
|
} else if (!oldExists && !newExists) {
|
||||||
|
try {
|
||||||
|
logger.info(`Updating config.name for repository "${repo.name}" (no folder exists yet)`);
|
||||||
|
|
||||||
|
const updatedConfig: RepositoryConfig = {
|
||||||
|
...config,
|
||||||
|
name: repo.shortId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(repositoriesTable)
|
||||||
|
.set({
|
||||||
|
config: updatedConfig,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(repositoriesTable.id, repo.id));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
|
||||||
|
}
|
||||||
|
} else if (newExists && !oldExists && config.name !== repo.shortId) {
|
||||||
|
try {
|
||||||
|
logger.info(`Folder already at new path, updating config.name for repository "${repo.name}"`);
|
||||||
|
|
||||||
|
const updatedConfig: RepositoryConfig = {
|
||||||
|
...config,
|
||||||
|
name: repo.shortId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(repositoriesTable)
|
||||||
|
.set({
|
||||||
|
config: updatedConfig,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(repositoriesTable.id, repo.id));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: errors.length === 0, errors };
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathExists = async (p: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { NotificationConfig } from "~/schemas/notifications";
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
|
||||||
export function buildPushoverShoutrrrUrl(
|
export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { type: "pushover" }>): string {
|
||||||
config: Extract<NotificationConfig, { type: "pushover" }>,
|
|
||||||
): string {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (config.devices) {
|
if (config.devices) {
|
||||||
|
|||||||
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}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
@@ -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,
|
||||||
@@ -157,17 +167,17 @@ const updateDestination = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Partial<NotificationDestination> = {
|
const updateData: Partial<NotificationDestination> = {
|
||||||
updatedAt: Math.floor(Date.now() / 1000),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (updates.name !== undefined) {
|
if (updates.name !== undefined) {
|
||||||
const slug = slugify(updates.name, { lower: true, strict: true });
|
const slug = slugify(updates.name, { lower: true, strict: true });
|
||||||
|
|
||||||
const conflict = await db.query.notificationDestinationsTable.findFirst({
|
const conflict = await db.query.notificationDestinationsTable.findFirst({
|
||||||
where: and(eq(notificationDestinationsTable.name, slug), eq(notificationDestinationsTable.id, id)),
|
where: and(eq(notificationDestinationsTable.name, slug), ne(notificationDestinationsTable.id, id)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (conflict && conflict.id !== id) {
|
if (conflict) {
|
||||||
throw new ConflictError("Notification destination with this name already exists");
|
throw new ConflictError("Notification destination with this name already exists");
|
||||||
}
|
}
|
||||||
updateData.name = slug;
|
updateData.name = slug;
|
||||||
@@ -291,6 +301,7 @@ const sendBackupNotification = async (
|
|||||||
case "success":
|
case "success":
|
||||||
return assignment.notifyOnSuccess;
|
return assignment.notifyOnSuccess;
|
||||||
case "failure":
|
case "failure":
|
||||||
|
case "warning":
|
||||||
return assignment.notifyOnFailure;
|
return assignment.notifyOnFailure;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
@@ -367,7 +378,7 @@ function buildNotificationMessage(
|
|||||||
|
|
||||||
case "success":
|
case "success":
|
||||||
return {
|
return {
|
||||||
title: "✅ Backup Completed Successfully",
|
title: "✅ Backup Completed successfully",
|
||||||
body: [
|
body: [
|
||||||
`Volume: ${context.volumeName}`,
|
`Volume: ${context.volumeName}`,
|
||||||
`Repository: ${context.repositoryName}`,
|
`Repository: ${context.repositoryName}`,
|
||||||
@@ -381,9 +392,26 @@ function buildNotificationMessage(
|
|||||||
.join("\n"),
|
.join("\n"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case "warning":
|
||||||
|
return {
|
||||||
|
title: "! Backup completed with warnings",
|
||||||
|
body: [
|
||||||
|
`Volume: ${context.volumeName}`,
|
||||||
|
`Repository: ${context.repositoryName}`,
|
||||||
|
context.duration ? `Duration: ${Math.round(context.duration / 1000)}s` : null,
|
||||||
|
context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null,
|
||||||
|
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null,
|
||||||
|
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null,
|
||||||
|
context.error ? `Warning: ${context.error}` : null,
|
||||||
|
`Time: ${date} - ${time}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
case "failure":
|
case "failure":
|
||||||
return {
|
return {
|
||||||
title: "❌ Backup Failed",
|
title: "❌ Backup failed",
|
||||||
body: [
|
body: [
|
||||||
`Volume: ${context.volumeName}`,
|
`Volume: ${context.volumeName}`,
|
||||||
`Repository: ${context.repositoryName}`,
|
`Repository: ${context.repositoryName}`,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
listSnapshotsFilters,
|
listSnapshotsFilters,
|
||||||
restoreSnapshotBody,
|
restoreSnapshotBody,
|
||||||
restoreSnapshotDto,
|
restoreSnapshotDto,
|
||||||
|
updateRepositoryBody,
|
||||||
|
updateRepositoryDto,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
type DeleteSnapshotDto,
|
type DeleteSnapshotDto,
|
||||||
type DoctorRepositoryDto,
|
type DoctorRepositoryDto,
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
type ListSnapshotFilesDto,
|
type ListSnapshotFilesDto,
|
||||||
type ListSnapshotsDto,
|
type ListSnapshotsDto,
|
||||||
type RestoreSnapshotDto,
|
type RestoreSnapshotDto,
|
||||||
|
type UpdateRepositoryDto,
|
||||||
} from "./repositories.dto";
|
} from "./repositories.dto";
|
||||||
import { repositoriesService } from "./repositories.service";
|
import { repositoriesService } from "./repositories.service";
|
||||||
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
|
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
|
||||||
@@ -152,4 +155,12 @@ export const repositoriesController = new Hono()
|
|||||||
await repositoriesService.deleteSnapshot(name, snapshotId);
|
await repositoriesService.deleteSnapshot(name, snapshotId);
|
||||||
|
|
||||||
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
|
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
|
||||||
|
})
|
||||||
|
.patch("/:name", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
|
||||||
|
const { name } = c.req.param();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const res = await repositoriesService.updateRepository(name, body);
|
||||||
|
|
||||||
|
return c.json<UpdateRepositoryDto>(res.repository, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
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",
|
||||||
|
shortId: "string",
|
||||||
name: "string",
|
name: "string",
|
||||||
type: type.valueOf(REPOSITORY_BACKENDS),
|
type: type.valueOf(REPOSITORY_BACKENDS),
|
||||||
config: repositoryConfigSchema,
|
config: repositoryConfigSchema,
|
||||||
@@ -123,6 +130,41 @@ export const deleteRepositoryDto = describeRoute({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a repository
|
||||||
|
*/
|
||||||
|
export const updateRepositoryBody = type({
|
||||||
|
name: "string?",
|
||||||
|
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateRepositoryBody = typeof updateRepositoryBody.infer;
|
||||||
|
|
||||||
|
export const updateRepositoryResponse = repositorySchema;
|
||||||
|
export type UpdateRepositoryDto = typeof updateRepositoryResponse.infer;
|
||||||
|
|
||||||
|
export const updateRepositoryDto = describeRoute({
|
||||||
|
description: "Update a repository's name or settings",
|
||||||
|
tags: ["Repositories"],
|
||||||
|
operationId: "updateRepository",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Repository updated successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(updateRepositoryResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: "Repository not found",
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: "Repository with this name already exists",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List snapshots in a repository
|
* List snapshots in a repository
|
||||||
*/
|
*/
|
||||||
@@ -233,12 +275,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;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { repositoriesTable } from "../../db/schema";
|
import { repositoriesTable } from "../../db/schema";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
|
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({});
|
||||||
@@ -61,13 +63,20 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
const shortId = generateShortId();
|
||||||
|
|
||||||
const encryptedConfig = await encryptConfig(config);
|
let processedConfig = config;
|
||||||
|
if (config.backend === "local") {
|
||||||
|
processedConfig = { ...config, name: shortId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedConfig = await encryptConfig(processedConfig);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(repositoriesTable)
|
.insert(repositoriesTable)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
|
shortId,
|
||||||
name: slug,
|
name: slug,
|
||||||
type: config.backend,
|
type: config.backend,
|
||||||
config: encryptedConfig,
|
config: encryptedConfig,
|
||||||
@@ -152,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) => {
|
||||||
@@ -172,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),
|
||||||
@@ -203,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) => {
|
||||||
@@ -222,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) => {
|
||||||
@@ -241,21 +279,23 @@ const checkHealth = async (repositoryId: string) => {
|
|||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error, status } = await restic
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
|
||||||
.snapshots(repository.config)
|
try {
|
||||||
.then(() => ({ error: null, status: "healthy" as const }))
|
const { hasErrors, error } = await restic.check(repository.config);
|
||||||
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(repositoriesTable)
|
.update(repositoriesTable)
|
||||||
.set({
|
.set({
|
||||||
status,
|
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 { status, lastError: error };
|
return { lastError: error };
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doctorRepository = async (name: string) => {
|
const doctorRepository = async (name: string) => {
|
||||||
@@ -281,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({
|
||||||
@@ -347,7 +390,62 @@ 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 existing = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: eq(repositoriesTable.name, name),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
updates.name !== undefined &&
|
||||||
|
updates.name !== existing.name &&
|
||||||
|
existing.config.backend === "local" &&
|
||||||
|
existing.config.isExistingRepository
|
||||||
|
) {
|
||||||
|
throw new ConflictError("Cannot rename an imported local repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
let newName = existing.name;
|
||||||
|
if (updates.name !== undefined && updates.name !== existing.name) {
|
||||||
|
const newSlug = slugify(updates.name, { lower: true, strict: true });
|
||||||
|
|
||||||
|
const conflict = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: and(eq(repositoriesTable.name, newSlug), ne(repositoriesTable.id, existing.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflict) {
|
||||||
|
throw new ConflictError("A repository with this name already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
newName = newSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(repositoriesTable)
|
||||||
|
.set({
|
||||||
|
name: newName,
|
||||||
|
compressionMode: updates.compressionMode ?? existing.compressionMode,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(repositoriesTable.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new InternalServerError("Failed to update repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { repository: updated };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const repositoriesService = {
|
export const repositoriesService = {
|
||||||
@@ -355,6 +453,7 @@ export const repositoriesService = {
|
|||||||
createRepository,
|
createRepository,
|
||||||
getRepository,
|
getRepository,
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
|
updateRepository,
|
||||||
listSnapshots,
|
listSnapshots,
|
||||||
listSnapshotFiles,
|
listSnapshotFiles,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ export const getVolumePath = (volume: Volume) => {
|
|||||||
return volume.config.path;
|
return volume.config.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
|
return `${VOLUME_MOUNT_BASE}/${volume.shortId}/_data`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/vol
|
|||||||
|
|
||||||
export const volumeSchema = type({
|
export const volumeSchema = type({
|
||||||
id: "number",
|
id: "number",
|
||||||
|
shortId: "string",
|
||||||
name: "string",
|
name: "string",
|
||||||
type: type.valueOf(BACKEND_TYPES),
|
type: type.valueOf(BACKEND_TYPES),
|
||||||
status: type.valueOf(BACKEND_STATUS),
|
status: type.valueOf(BACKEND_STATUS),
|
||||||
@@ -128,6 +129,7 @@ export const getVolumeDto = describeRoute({
|
|||||||
* Update a volume
|
* Update a volume
|
||||||
*/
|
*/
|
||||||
export const updateVolumeBody = type({
|
export const updateVolumeBody = type({
|
||||||
|
name: "string?",
|
||||||
autoRemount: "boolean?",
|
autoRemount: "boolean?",
|
||||||
config: volumeConfigSchema.optional(),
|
config: volumeConfigSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import * as fs from "node:fs/promises";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { getCapabilities } from "../../core/capabilities";
|
import { getCapabilities } from "../../core/capabilities";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { volumesTable } from "../../db/schema";
|
import { volumesTable } from "../../db/schema";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
|
import { generateShortId } from "../../utils/id";
|
||||||
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
||||||
import { withTimeout } from "../../utils/timeout";
|
import { withTimeout } from "../../utils/timeout";
|
||||||
import { createVolumeBackend } from "../backends/backend";
|
import { createVolumeBackend } from "../backends/backend";
|
||||||
@@ -35,9 +36,12 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||||||
throw new ConflictError("Volume already exists");
|
throw new ConflictError("Volume already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shortId = generateShortId();
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(volumesTable)
|
.insert(volumesTable)
|
||||||
.values({
|
.values({
|
||||||
|
shortId,
|
||||||
name: slug,
|
name: slug,
|
||||||
config: backendConfig,
|
config: backendConfig,
|
||||||
type: backendConfig.backend,
|
type: backendConfig.backend,
|
||||||
@@ -147,6 +151,21 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||||||
throw new NotFoundError("Volume not found");
|
throw new NotFoundError("Volume not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let newName = existing.name;
|
||||||
|
if (volumeData.name !== undefined && volumeData.name !== existing.name) {
|
||||||
|
const newSlug = slugify(volumeData.name, { lower: true, strict: true });
|
||||||
|
|
||||||
|
const conflict = await db.query.volumesTable.findFirst({
|
||||||
|
where: and(eq(volumesTable.name, newSlug), ne(volumesTable.id, existing.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflict) {
|
||||||
|
throw new ConflictError("A volume with this name already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
newName = newSlug;
|
||||||
|
}
|
||||||
|
|
||||||
const configChanged =
|
const configChanged =
|
||||||
JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined;
|
JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined;
|
||||||
|
|
||||||
@@ -159,12 +178,13 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(volumesTable)
|
.update(volumesTable)
|
||||||
.set({
|
.set({
|
||||||
|
name: newName,
|
||||||
config: volumeData.config,
|
config: volumeData.config,
|
||||||
type: volumeData.config?.backend,
|
type: volumeData.config?.backend,
|
||||||
autoRemount: volumeData.autoRemount,
|
autoRemount: volumeData.autoRemount,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
.where(eq(volumesTable.name, name))
|
.where(eq(volumesTable.id, existing.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
@@ -177,9 +197,9 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||||||
await db
|
await db
|
||||||
.update(volumesTable)
|
.update(volumesTable)
|
||||||
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
||||||
.where(eq(volumesTable.name, name));
|
.where(eq(volumesTable.id, existing.id));
|
||||||
|
|
||||||
serverEvents.emit("volume:updated", { volumeName: name });
|
serverEvents.emit("volume:updated", { volumeName: updated.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { volume: updated };
|
return { volume: updated };
|
||||||
@@ -190,6 +210,7 @@ const testConnection = async (backendConfig: BackendConfig) => {
|
|||||||
|
|
||||||
const mockVolume = {
|
const mockVolume = {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
shortId: "test",
|
||||||
name: "test-connection",
|
name: "test-connection",
|
||||||
path: tempDir,
|
path: tempDir,
|
||||||
config: backendConfig,
|
config: backendConfig,
|
||||||
@@ -264,7 +285,7 @@ const getContainersUsingVolume = async (name: string) => {
|
|||||||
const container = docker.getContainer(info.Id);
|
const container = docker.getContainer(info.Id);
|
||||||
const inspect = await container.inspect();
|
const inspect = await container.inspect();
|
||||||
const mounts = inspect.Mounts || [];
|
const mounts = inspect.Mounts || [];
|
||||||
const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `im-${volume.name}`);
|
const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `zb-${volume.shortId}`);
|
||||||
if (usesVolume) {
|
if (usesVolume) {
|
||||||
usingContainers.push({
|
usingContainers.push({
|
||||||
id: inspect.Id,
|
id: inspect.Id,
|
||||||
|
|||||||
@@ -17,3 +17,25 @@ export const toMessage = (err: unknown): string => {
|
|||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return sanitizeSensitiveData(message);
|
return sanitizeSensitiveData(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resticErrorCodes: Record<number, string> = {
|
||||||
|
1: "Command failed: An error occurred while executing the command.",
|
||||||
|
2: "Go runtime error: A runtime error occurred in the Go program.",
|
||||||
|
3: "Backup could not read all files: Some files could not be read during backup.",
|
||||||
|
10: "Repository not found: The specified repository could not be found.",
|
||||||
|
11: "Failed to lock repository: Unable to acquire a lock on the repository. Try to run doctor on the repository.",
|
||||||
|
12: "Wrong repository password: The provided password for the repository is incorrect.",
|
||||||
|
130: "Backup interrupted: The backup process was interrupted.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ResticError extends Error {
|
||||||
|
code: number;
|
||||||
|
|
||||||
|
constructor(code: number, stderr: string) {
|
||||||
|
const message = resticErrorCodes[code] || `Unknown restic error with code ${code}`;
|
||||||
|
super(`${message}\n${stderr}`);
|
||||||
|
|
||||||
|
this.code = code;
|
||||||
|
this.name = "ResticError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
6
app/server/utils/id.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export const generateShortId = (length = 5): string => {
|
||||||
|
const bytesNeeded = Math.ceil((length * 3) / 4);
|
||||||
|
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length);
|
||||||
|
};
|
||||||
@@ -9,7 +9,8 @@ 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";
|
||||||
|
|
||||||
const backupOutputSchema = type({
|
const backupOutputSchema = type({
|
||||||
message_type: "'summary'",
|
message_type: "'summary'",
|
||||||
@@ -199,8 +200,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);
|
||||||
@@ -276,8 +277,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());
|
||||||
@@ -313,39 +313,46 @@ const backup = async (
|
|||||||
streamProgress(data);
|
streamProgress(data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStderr: (error) => {
|
|
||||||
logger.error(error.trim());
|
|
||||||
},
|
|
||||||
finally: async () => {
|
finally: async () => {
|
||||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||||
await cleanupTemporaryKeys(config, env);
|
await cleanupTemporaryKeys(config, env);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode === 3) {
|
||||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
logger.error(`Restic backup encountered read errors: ${res.stderr.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.exitCode !== 0 && res.exitCode !== 3) {
|
||||||
|
logger.error(`Restic backup failed: ${res.stderr.toString()}`);
|
||||||
logger.error(`Command executed: restic ${args.join(" ")}`);
|
logger.error(`Command executed: restic ${args.join(" ")}`);
|
||||||
|
|
||||||
throw new Error(`Restic backup failed: ${res.stderr}`);
|
throw new ResticError(res.exitCode, res.stderr.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastLine = stdout.trim();
|
const lastLine = stdout.trim();
|
||||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
let summaryLine = "";
|
||||||
|
try {
|
||||||
|
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||||
|
summaryLine = resSummary;
|
||||||
|
} catch (_) {
|
||||||
|
logger.warn("Failed to parse restic backup output JSON summary.", lastLine);
|
||||||
|
summaryLine = "{}";
|
||||||
|
}
|
||||||
|
|
||||||
const result = backupOutputSchema(resSummary);
|
const result = backupOutputSchema(summaryLine);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
logger.error(`Restic backup output validation failed: ${result}`);
|
logger.error(`Restic backup output validation failed: ${result}`);
|
||||||
|
return { result: null, exitCode: res.exitCode };
|
||||||
throw new Error(`Restic backup output validation failed: ${result}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return { result, exitCode: res.exitCode };
|
||||||
};
|
};
|
||||||
|
|
||||||
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?",
|
||||||
@@ -361,8 +368,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);
|
||||||
@@ -370,8 +377,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) {
|
||||||
@@ -396,15 +403,15 @@ 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);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||||
throw new Error(`Restic restore failed: ${res.stderr}`);
|
throw new ResticError(res.exitCode, res.stderr.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const stdout = res.text();
|
const stdout = res.text();
|
||||||
@@ -459,8 +466,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);
|
||||||
@@ -509,15 +515,14 @@ 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);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||||
throw new Error(`Restic forget failed: ${res.stderr}`);
|
throw new ResticError(res.exitCode, res.stderr.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -528,14 +533,14 @@ 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);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||||
throw new Error(`Failed to delete snapshot: ${res.stderr}`);
|
throw new ResticError(res.exitCode, res.stderr.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -572,20 +577,20 @@ 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);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||||
throw new Error(`Restic ls failed: ${res.stderr}`);
|
throw new ResticError(res.exitCode, res.stderr.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
||||||
@@ -628,15 +633,15 @@ 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);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||||
throw new Error(`Restic unlock failed: ${res.stderr}`);
|
throw new ResticError(res.exitCode, res.stderr.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
|
logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
|
||||||
@@ -653,7 +658,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);
|
||||||
@@ -687,7 +692,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);
|
||||||
@@ -697,7 +702,7 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic repair index failed: ${stderr}`);
|
logger.error(`Restic repair index failed: ${stderr}`);
|
||||||
throw new Error(`Restic repair index failed: ${stderr}`);
|
throw new ResticError(res.exitCode, stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Restic repair index completed for repository: ${repoUrl}`);
|
logger.info(`Restic repair index completed for repository: ${repoUrl}`);
|
||||||
@@ -708,7 +713,9 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, 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) {
|
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
||||||
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:***@");
|
||||||
|
|||||||
@@ -41,9 +41,8 @@ export const safeSpawn = (params: Params) => {
|
|||||||
child.stderr.on("data", (data) => {
|
child.stderr.on("data", (data) => {
|
||||||
if (callbacks.onStderr) {
|
if (callbacks.onStderr) {
|
||||||
callbacks.onStderr(data.toString());
|
callbacks.onStderr(data.toString());
|
||||||
} else {
|
|
||||||
stderrData += data.toString();
|
|
||||||
}
|
}
|
||||||
|
stderrData += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("error", async (error) => {
|
child.on("error", async (error) => {
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ services:
|
|||||||
|
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
- ~/.config/rclone:/root/.config/rclone
|
- ~/.config/rclone:/root/.config/rclone
|
||||||
- /var/lib/zerobyte:/var/lib/zerobyte:rshared
|
# - /run/docker/plugins:/run/docker/plugins
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
# - /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
|
|
||||||
zerobyte-prod:
|
zerobyte-prod:
|
||||||
build:
|
build:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "Zerobyte",
|
"name": "Zerobyte",
|
||||||
"short_name": "Zerobyte",
|
"short_name": "Zerobyte",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/favicon/web-app-manifest-192x192.png",
|
"src": "/images/favicon/web-app-manifest-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/favicon/web-app-manifest-512x512.png",
|
"src": "/images/favicon/web-app-manifest-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"theme_color": "#1b1b1b",
|
"theme_color": "#1b1b1b",
|
||||||
"background_color": "#1b1b1b",
|
"background_color": "#1b1b1b",
|
||||||
"display": "standalone"
|
"display": "standalone"
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 28 KiB |