Compare commits

...

11 Commits

Author SHA1 Message Date
Nicolas Meienberger
4328607cc1 fix: skip renaming imported repository 2025-11-26 22:20:42 +01:00
Nicolas Meienberger
bedd325a60 fix(db): set pragma after migrations 2025-11-26 20:12:12 +01:00
Nico
b26a062648 refactor: use short ids to allow changing the name of volumes & repos (#67)
* refactor: use short ids to allow changing the name of volumes & repos

* refactor: address PR feedbacks

* fix: make short_id non null after initial population
2025-11-26 19:47:09 +01:00
Nico
d190d9c8cd feat: partial success warning status (#74)
* feat: report partial backups with warnings

* chore: rebase

* chore: remove un-used size prop
2025-11-26 19:02:29 +01:00
Nicolas Meienberger
f8363a6c71 docs: update readme with TZ environment 2025-11-25 18:25:22 +01:00
Nico
59b2b53837 docs: update README 2025-11-23 21:21:52 +01:00
Nicolas Meienberger
e99487eed9 fix(notifications): multiple providers using the wrong params 2025-11-23 21:09:23 +01:00
Nicolas Meienberger
8d4e5d2d4e fix(ntfy): wrong usage of token 2025-11-23 20:49:44 +01:00
Nicolas Meienberger
daea3e64e4 fix(smtp-notification): always use smtp:// 2025-11-23 20:37:21 +01:00
Nicolas Meienberger
70df79079f fix(backups): correctly apply repository compression mode 2025-11-23 17:53:13 +01:00
Nicolas Meienberger
f1096220dd chore: update readme first screenshot 2025-11-23 11:28:27 +01:00
49 changed files with 3684 additions and 103 deletions

View File

@@ -6,7 +6,7 @@
</a> </a>
<br /> <br />
<figure> <figure>
<img src="https://github.com/nicotsx/zerobyte/blob/main/screenshots/backup-details.png?raw=true" alt="Demo" /> <img src="https://github.com/nicotsx/zerobyte/blob/main/screenshots/backup-details.webp?raw=true" alt="Demo" />
<figcaption> <figcaption>
<p align="center"> <p align="center">
Backup management with scheduling and monitoring Backup management with scheduling and monitoring
@@ -18,6 +18,10 @@
> [!WARNING] > [!WARNING]
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests > Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
<p align="center">
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p>
## Intro ## Intro
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage. Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
@@ -45,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
@@ -81,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
@@ -147,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
@@ -202,6 +212,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
@@ -233,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
@@ -251,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:
@@ -261,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

View File

@@ -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);
/** /**

View File

@@ -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
*/ */

View File

@@ -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;
@@ -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;
@@ -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' | 'better' | 'fastest' | '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' | 'better' | 'fastest' | '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: {
@@ -1181,7 +1292,7 @@ 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' | 'better' | 'fastest' | 'max' | 'off' | null;
@@ -1251,6 +1362,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 +1416,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 +1464,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,7 +1525,7 @@ 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' | 'better' | 'fastest' | 'max' | 'off' | null;
@@ -1482,6 +1595,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 +1649,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 +1698,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,7 +1739,7 @@ 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' | 'better' | 'fastest' | 'max' | 'off' | null;
@@ -1694,6 +1809,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 +1863,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;
@@ -1859,13 +1976,15 @@ export type GetScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
password?: string;
serverUrl?: string; serverUrl?: string;
token?: string; username?: string;
} | { } | {
priority: number; priority: number;
serverUrl: string; serverUrl: string;
token: string; token: string;
type: 'gotify'; type: 'gotify';
path?: string;
} | { } | {
shoutrrrUrl: string; shoutrrrUrl: string;
type: 'custom'; type: 'custom';
@@ -1873,6 +1992,7 @@ export type GetScheduleNotificationsResponses = {
type: 'discord'; type: 'discord';
webhookUrl: string; webhookUrl: string;
avatarUrl?: string; avatarUrl?: string;
threadId?: string;
username?: string; username?: string;
} | { } | {
type: 'slack'; type: 'slack';
@@ -1940,13 +2060,15 @@ export type UpdateScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
password?: string;
serverUrl?: string; serverUrl?: string;
token?: string; username?: string;
} | { } | {
priority: number; priority: number;
serverUrl: string; serverUrl: string;
token: string; token: string;
type: 'gotify'; type: 'gotify';
path?: string;
} | { } | {
shoutrrrUrl: string; shoutrrrUrl: string;
type: 'custom'; type: 'custom';
@@ -1954,6 +2076,7 @@ export type UpdateScheduleNotificationsResponses = {
type: 'discord'; type: 'discord';
webhookUrl: string; webhookUrl: string;
avatarUrl?: string; avatarUrl?: string;
threadId?: string;
username?: string; username?: string;
} | { } | {
type: 'slack'; type: 'slack';
@@ -2010,13 +2133,15 @@ export type ListNotificationDestinationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
password?: string;
serverUrl?: string; serverUrl?: string;
token?: string; username?: string;
} | { } | {
priority: number; priority: number;
serverUrl: string; serverUrl: string;
token: string; token: string;
type: 'gotify'; type: 'gotify';
path?: string;
} | { } | {
shoutrrrUrl: string; shoutrrrUrl: string;
type: 'custom'; type: 'custom';
@@ -2024,6 +2149,7 @@ export type ListNotificationDestinationsResponses = {
type: 'discord'; type: 'discord';
webhookUrl: string; webhookUrl: string;
avatarUrl?: string; avatarUrl?: string;
threadId?: string;
username?: string; username?: string;
} | { } | {
type: 'slack'; type: 'slack';
@@ -2064,13 +2190,15 @@ export type CreateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
password?: string;
serverUrl?: string; serverUrl?: string;
token?: string; username?: string;
} | { } | {
priority: number; priority: number;
serverUrl: string; serverUrl: string;
token: string; token: string;
type: 'gotify'; type: 'gotify';
path?: string;
} | { } | {
shoutrrrUrl: string; shoutrrrUrl: string;
type: 'custom'; type: 'custom';
@@ -2078,6 +2206,7 @@ export type CreateNotificationDestinationData = {
type: 'discord'; type: 'discord';
webhookUrl: string; webhookUrl: string;
avatarUrl?: string; avatarUrl?: string;
threadId?: string;
username?: string; username?: string;
} | { } | {
type: 'slack'; type: 'slack';
@@ -2117,13 +2246,15 @@ export type CreateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
password?: string;
serverUrl?: string; serverUrl?: string;
token?: string; username?: string;
} | { } | {
priority: number; priority: number;
serverUrl: string; serverUrl: string;
token: string; token: string;
type: 'gotify'; type: 'gotify';
path?: string;
} | { } | {
shoutrrrUrl: string; shoutrrrUrl: string;
type: 'custom'; type: 'custom';
@@ -2131,6 +2262,7 @@ export type CreateNotificationDestinationResponses = {
type: 'discord'; type: 'discord';
webhookUrl: string; webhookUrl: string;
avatarUrl?: string; avatarUrl?: string;
threadId?: string;
username?: string; username?: string;
} | { } | {
type: 'slack'; type: 'slack';
@@ -2217,13 +2349,15 @@ export type GetNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
password?: string;
serverUrl?: string; serverUrl?: string;
token?: string; username?: string;
} | { } | {
priority: number; priority: number;
serverUrl: string; serverUrl: string;
token: string; token: string;
type: 'gotify'; type: 'gotify';
path?: string;
} | { } | {
shoutrrrUrl: string; shoutrrrUrl: string;
type: 'custom'; type: 'custom';
@@ -2231,6 +2365,7 @@ export type GetNotificationDestinationResponses = {
type: 'discord'; type: 'discord';
webhookUrl: string; webhookUrl: string;
avatarUrl?: string; avatarUrl?: string;
threadId?: string;
username?: string; username?: string;
} | { } | {
type: 'slack'; type: 'slack';
@@ -2271,13 +2406,15 @@ export type UpdateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
password?: string;
serverUrl?: string; serverUrl?: string;
token?: string; username?: string;
} | { } | {
priority: number; priority: number;
serverUrl: string; serverUrl: string;
token: string; token: string;
type: 'gotify'; type: 'gotify';
path?: string;
} | { } | {
shoutrrrUrl: string; shoutrrrUrl: string;
type: 'custom'; type: 'custom';
@@ -2285,6 +2422,7 @@ export type UpdateNotificationDestinationData = {
type: 'discord'; type: 'discord';
webhookUrl: string; webhookUrl: string;
avatarUrl?: string; avatarUrl?: string;
threadId?: string;
username?: string; username?: string;
} | { } | {
type: 'slack'; type: 'slack';
@@ -2334,13 +2472,15 @@ export type UpdateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min'; priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string; topic: string;
type: 'ntfy'; type: 'ntfy';
password?: string;
serverUrl?: string; serverUrl?: string;
token?: string; username?: string;
} | { } | {
priority: number; priority: number;
serverUrl: string; serverUrl: string;
token: string; token: string;
type: 'gotify'; type: 'gotify';
path?: string;
} | { } | {
shoutrrrUrl: string; shoutrrrUrl: string;
type: 'custom'; type: 'custom';
@@ -2348,6 +2488,7 @@ export type UpdateNotificationDestinationResponses = {
type: 'discord'; type: 'discord';
webhookUrl: string; webhookUrl: string;
avatarUrl?: string; avatarUrl?: string;
threadId?: string;
username?: string; username?: string;
} | { } | {
type: 'slack'; type: 'slack';

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -370,6 +370,22 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="threadId"
render={({ field }) => (
<FormItem>
<FormLabel>Thread ID (Optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
ID of the thread to post messages in. Leave empty to post in the main channel.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</> </>
)} )}
@@ -423,6 +439,20 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="/custom/path" />
</FormControl>
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</> </>
)} )}
@@ -458,14 +488,28 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
/> />
<FormField <FormField
control={form.control} control={form.control}
name="token" name="username"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Access Token (Optional)</FormLabel> <FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username" />
</FormControl>
<FormDescription>Username for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="password" placeholder="••••••••" /> <Input {...field} type="password" placeholder="••••••••" />
</FormControl> </FormControl>
<FormDescription>Required if the topic is protected.</FormDescription> <FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -142,7 +142,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
&nbsp; &nbsp;
{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

View File

@@ -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,

View 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`);

View 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
);

View 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`);

View 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`);

View File

@@ -0,0 +1,648 @@
{
"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": {}
}
}

View File

@@ -0,0 +1,654 @@
{
"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": {}
}
}

View File

@@ -0,0 +1,688 @@
{
"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": {}
}
}

View File

@@ -0,0 +1,688 @@
{
"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": {}
}
}

View File

@@ -85,6 +85,34 @@
"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
} }
] ]
} }

View File

@@ -36,12 +36,14 @@ export const discordNotificationConfigSchema = type({
webhookUrl: "string", webhookUrl: "string",
username: "string?", username: "string?",
avatarUrl: "string?", avatarUrl: "string?",
threadId: "string?",
}); });
export const gotifyNotificationConfigSchema = type({ export const gotifyNotificationConfigSchema = type({
type: "'gotify'", type: "'gotify'",
serverUrl: "string", serverUrl: "string",
token: "string", token: "string",
path: "string?",
priority: "0 <= number <= 10", priority: "0 <= number <= 10",
}); });
@@ -49,8 +51,9 @@ export const ntfyNotificationConfigSchema = type({
type: "'ntfy'", type: "'ntfy'",
serverUrl: "string?", serverUrl: "string?",
topic: "string", topic: "string",
token: "string?",
priority: "'max' | 'high' | 'default' | 'low' | 'min'", priority: "'max' | 'high' | 'default' | 'low' | 'min'",
username: "string?",
password: "string?",
}); });
export const pushoverNotificationConfigSchema = type({ export const pushoverNotificationConfigSchema = type({
@@ -80,6 +83,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;

View File

@@ -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"];

View File

@@ -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;

View File

@@ -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;");
}; };

View File

@@ -9,6 +9,7 @@ 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"),
@@ -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(),
@@ -85,7 +87,7 @@ 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())`),
@@ -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())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type AppMetadata = typeof appMetadataTable.$inferSelect;

View File

@@ -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) {

View File

@@ -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",

View File

@@ -236,8 +236,9 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.include = schedule.includePatterns; backupOptions.include = schedule.includePatterns;
} }
await restic.backup(repository.config, volumePath, { const { exitCode } = await restic.backup(repository.config, volumePath, {
...backupOptions, ...backupOptions,
compressionMode: repository.compressionMode ?? "auto",
onProgress: (progress) => { onProgress: (progress) => {
serverEvents.emit("backup:progress", { serverEvents.emit("backup:progress", {
scheduleId, scheduleId,
@@ -257,24 +258,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,
}) })

View File

@@ -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: {},
})); }));

View File

@@ -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),

View File

@@ -0,0 +1,89 @@
import { eq, sql } from "drizzle-orm";
import { db } from "../../db/db";
import { appMetadataTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
import { REQUIRED_MIGRATIONS } from "~/server/core/constants";
const MIGRATION_KEY_PREFIX = "migration:";
export const recordMigrationCheckpoint = async (version: string): Promise<void> => {
const key = `${MIGRATION_KEY_PREFIX}${version}`;
const now = Math.floor(Date.now() / 1000);
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,
}));
};

View 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: Math.floor(Date.now() / 1000),
})
.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: Math.floor(Date.now() / 1000),
})
.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: Math.floor(Date.now() / 1000),
})
.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;
}
};

View File

@@ -17,7 +17,10 @@ export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { ty
params.append("username", config.username); params.append("username", config.username);
} }
if (config.avatarUrl) { if (config.avatarUrl) {
params.append("avatar_url", config.avatarUrl); params.append("avatarurl", config.avatarUrl);
}
if (config.threadId) {
params.append("thread_id", config.threadId);
} }
if (params.toString()) { if (params.toString()) {

View File

@@ -1,10 +1,10 @@
import type { NotificationConfig } from "~/schemas/notifications"; import type { NotificationConfig } from "~/schemas/notifications";
export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string { export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string {
const protocol = config.useTLS ? "smtps" : "smtp";
const auth = `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}`; const auth = `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}`;
const host = `${config.smtpHost}:${config.smtpPort}`; const host = `${config.smtpHost}:${config.smtpPort}`;
const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(","); const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(",");
const useStartTLS = config.useTLS ? "yes" : "no";
return `${protocol}://${auth}@${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}`; return `smtp://${auth}@${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}&starttls=${useStartTLS}`;
} }

View File

@@ -4,8 +4,9 @@ export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { typ
const url = new URL(config.serverUrl); const url = new URL(config.serverUrl);
const hostname = url.hostname; const hostname = url.hostname;
const port = url.port ? `:${url.port}` : ""; const port = url.port ? `:${url.port}` : "";
const path = config.path ? `/${config.path.replace(/^\/+|\/+$/g, "")}` : "";
let shoutrrrUrl = `gotify://${hostname}${port}/${config.token}`; let shoutrrrUrl = `gotify://${hostname}${port}${path}/${config.token}`;
if (config.priority !== undefined) { if (config.priority !== undefined) {
shoutrrrUrl += `?priority=${config.priority}`; shoutrrrUrl += `?priority=${config.priority}`;

View File

@@ -3,19 +3,26 @@ import type { NotificationConfig } from "~/schemas/notifications";
export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string { export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string {
let shoutrrrUrl: string; let shoutrrrUrl: string;
const params = new URLSearchParams();
const auth =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
: "";
if (config.serverUrl) { if (config.serverUrl) {
const url = new URL(config.serverUrl); const url = new URL(config.serverUrl);
const hostname = url.hostname; const hostname = url.hostname;
const port = url.port ? `:${url.port}` : ""; const port = url.port ? `:${url.port}` : "";
shoutrrrUrl = `ntfy://${hostname}${port}/${config.topic}`; const scheme = url.protocol === "https:" ? "https" : "http";
params.append("scheme", scheme);
shoutrrrUrl = `ntfy://${auth}${hostname}${port}/${config.topic}`;
} else { } else {
shoutrrrUrl = `ntfy://ntfy.sh/${config.topic}`; shoutrrrUrl = `ntfy://${auth}ntfy.sh/${config.topic}`;
} }
const params = new URLSearchParams();
if (config.token) {
params.append("token", config.token);
}
if (config.priority) { if (config.priority) {
params.append("priority", config.priority); params.append("priority", config.priority);
} }

View File

@@ -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) {

View File

@@ -20,7 +20,7 @@ export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type
params.append("username", config.username); params.append("username", config.username);
} }
if (config.iconEmoji) { if (config.iconEmoji) {
params.append("icon", config.iconEmoji); params.append("icon_emoji", config.iconEmoji);
} }
if (params.toString()) { if (params.toString()) {

View File

@@ -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";
@@ -58,7 +58,7 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "ntfy": case "ntfy":
return { return {
...config, ...config,
token: config.token ? await cryptoUtils.encrypt(config.token) : undefined, password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
}; };
case "pushover": case "pushover":
return { return {
@@ -100,7 +100,7 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "ntfy": case "ntfy":
return { return {
...config, ...config,
token: config.token ? await cryptoUtils.decrypt(config.token) : undefined, password: config.password ? await cryptoUtils.decrypt(config.password) : undefined,
}; };
case "pushover": case "pushover":
return { return {
@@ -164,10 +164,10 @@ const updateDestination = async (
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 +291,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 +368,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 +382,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}`,

View File

@@ -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);
}); });

View File

@@ -4,6 +4,7 @@ import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryCo
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 +124,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
*/ */

View File

@@ -1,10 +1,11 @@
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 type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
@@ -61,13 +62,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,
@@ -350,11 +358,62 @@ const deleteSnapshot = async (name: string, snapshotId: string) => {
await restic.deleteSnapshot(repository.config, snapshotId); await restic.deleteSnapshot(repository.config, snapshotId);
}; };
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: Math.floor(Date.now() / 1000),
})
.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 = {
listRepositories, listRepositories,
createRepository, createRepository,
getRepository, getRepository,
deleteRepository, deleteRepository,
updateRepository,
listSnapshots, listSnapshots,
listSnapshotFiles, listSnapshotFiles,
restoreSnapshot, restoreSnapshot,

View File

@@ -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`;
}; };

View File

@@ -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(),
}); });

View File

@@ -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,

View File

@@ -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
View 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);
};

View File

@@ -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 { RepositoryConfig } from "~/schemas/restic"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import { ResticError } from "./errors";
const backupOutputSchema = type({ const backupOutputSchema = type({
message_type: "'summary'", message_type: "'summary'",
@@ -234,6 +235,7 @@ const backup = async (
exclude?: string[]; exclude?: string[];
include?: string[]; include?: string[];
tags?: string[]; tags?: string[];
compressionMode?: CompressionMode;
signal?: AbortSignal; signal?: AbortSignal;
onProgress?: (progress: BackupProgress) => void; onProgress?: (progress: BackupProgress) => void;
}, },
@@ -241,7 +243,14 @@ const backup = async (
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config); const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "backup", "--one-file-system"]; const args: string[] = [
"--repo",
repoUrl,
"backup",
"--one-file-system",
"--compression",
options?.compressionMode ?? "auto",
];
if (options?.tags && options.tags.length > 0) { if (options?.tags && options.tags.length > 0) {
for (const tag of options.tags) { for (const tag of options.tags) {
@@ -291,6 +300,7 @@ const backup = async (
let stdout = ""; let stdout = "";
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({ const res = await safeSpawn({
command: "restic", command: "restic",
args, args,
@@ -304,34 +314,41 @@ 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({
@@ -395,7 +412,7 @@ const restore = async (
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();
@@ -508,7 +525,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
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 };
@@ -526,7 +543,7 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
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 };
@@ -576,7 +593,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
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
@@ -627,7 +644,7 @@ const unlock = async (config: RepositoryConfig) => {
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}`);
@@ -688,7 +705,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}`);

View File

@@ -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) => {

View File

@@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB