Compare commits

...

16 Commits

Author SHA1 Message Date
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
Nicolas Meienberger
2418870284 docs: update readme 2025-11-22 17:51:58 +01:00
Nicolas Meienberger
43dfe6b190 chore: fix typing issue 2025-11-22 17:50:58 +01:00
Nicolas Meienberger
8c4939af4e feat(notifications): native support for pushover 2025-11-22 17:48:19 +01:00
Nicolas Meienberger
a622b5e689 feat: exclude specific xattr during restore 2025-11-22 17:39:08 +01:00
Nico
6c30e7e357 Feat/notifications alerts (#52)
* feat: notifications backend & creation

* feat: assign notification to backup schedule

* refactor: status dot one component

* chore(notification-details): remove refetchInterval
2025-11-22 14:58:21 +01:00
Nicolas Meienberger
043f73ea87 fix: properly decode path to support all special unicode characters 2025-11-21 18:25:27 +01:00
73 changed files with 7939 additions and 272 deletions

View File

@@ -14,24 +14,27 @@ WORKDIR /deps
ARG TARGETARCH
ARG RESTIC_VERSION="0.18.1"
ARG SHOUTRRR_VERSION="0.12.0"
ENV TARGETARCH=${TARGETARCH}
RUN apk add --no-cache curl bzip2
RUN apk add --no-cache curl bzip2 unzip tar
RUN echo "Building for ${TARGETARCH}"
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \
curl -O https://downloads.rclone.org/rclone-current-linux-arm64.zip; \
unzip rclone-current-linux-arm64.zip; \
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
elif [ "${TARGETARCH}" = "amd64" ]; then \
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip; \
unzip rclone-current-linux-amd64.zip; \
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
fi
RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
# DEVELOPMENT
@@ -44,6 +47,8 @@ WORKDIR /app
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
COPY ./package.json ./bun.lock ./
RUN bun install --frozen-lockfile
@@ -80,10 +85,11 @@ ENV NODE_ENV="production"
WORKDIR /app
COPY --from=builder /app/package.json ./
RUN bun install --production --frozen-lockfile
RUN bun install --production --frozen-lockfile --verbose
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
COPY --from=builder /app/dist/client ./dist/client
COPY --from=builder /app/dist/server ./dist/server
COPY --from=builder /app/app/drizzle ./assets/migrations

View File

@@ -6,7 +6,7 @@
</a>
<br />
<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>
<p align="center">
Backup management with scheduling and monitoring
@@ -18,6 +18,10 @@
> [!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
<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
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.
@@ -36,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.12
image: ghcr.io/nicotsx/zerobyte:v0.13
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -45,13 +49,15 @@ services:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris # Set your timezone here
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
```
> [!WARNING]
> Do not try to change the location of the bind mount `/var/lib/zerobyte` on your host or store it on a network share. You will likely face permission issues and strong performance degradation.
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
Then, run the following command to start Zerobyte:
@@ -72,7 +78,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.12
image: ghcr.io/nicotsx/zerobyte:v0.13
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -81,6 +87,8 @@ services:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@@ -138,7 +146,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.12
image: ghcr.io/nicotsx/zerobyte:v0.13
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -147,6 +155,8 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@@ -195,13 +205,15 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ze
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.12
image: ghcr.io/nicotsx/zerobyte:v0.13
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
@@ -224,7 +236,7 @@ In order to enable this feature, you need to run Zerobyte with several items sha
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.12
image: ghcr.io/nicotsx/zerobyte:v0.13
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -233,6 +245,8 @@ services:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /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:
```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:
@@ -261,13 +275,13 @@ services:
myservice:
image: nginx:latest
volumes:
- im-nfs:/path/in/container
- zb-abc12:/path/in/container
volumes:
im-nfs:
zb-abc12:
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
docker volume ls

View File

@@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, 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, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.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, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
/**
* Register a new user
@@ -442,6 +442,23 @@ export const getRepositoryOptions = (options: Options<GetRepositoryData>) => que
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);
/**
@@ -703,6 +720,145 @@ export const runForgetMutation = (options?: Partial<Options<RunForgetData>>): Us
return mutationOptions;
};
export const getScheduleNotificationsQueryKey = (options: Options<GetScheduleNotificationsData>) => createQueryKey("getScheduleNotifications", options);
/**
* Get notification assignments for a backup schedule
*/
export const getScheduleNotificationsOptions = (options: Options<GetScheduleNotificationsData>) => queryOptions<GetScheduleNotificationsResponse, DefaultError, GetScheduleNotificationsResponse, ReturnType<typeof getScheduleNotificationsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getScheduleNotifications({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getScheduleNotificationsQueryKey(options)
});
/**
* Update notification assignments for a backup schedule
*/
export const updateScheduleNotificationsMutation = (options?: Partial<Options<UpdateScheduleNotificationsData>>): UseMutationOptions<UpdateScheduleNotificationsResponse, DefaultError, Options<UpdateScheduleNotificationsData>> => {
const mutationOptions: UseMutationOptions<UpdateScheduleNotificationsResponse, DefaultError, Options<UpdateScheduleNotificationsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateScheduleNotifications({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey("listNotificationDestinations", options);
/**
* List all notification destinations
*/
export const listNotificationDestinationsOptions = (options?: Options<ListNotificationDestinationsData>) => queryOptions<ListNotificationDestinationsResponse, DefaultError, ListNotificationDestinationsResponse, ReturnType<typeof listNotificationDestinationsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listNotificationDestinations({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listNotificationDestinationsQueryKey(options)
});
/**
* Create a new notification destination
*/
export const createNotificationDestinationMutation = (options?: Partial<Options<CreateNotificationDestinationData>>): UseMutationOptions<CreateNotificationDestinationResponse, DefaultError, Options<CreateNotificationDestinationData>> => {
const mutationOptions: UseMutationOptions<CreateNotificationDestinationResponse, DefaultError, Options<CreateNotificationDestinationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await createNotificationDestination({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete a notification destination
*/
export const deleteNotificationDestinationMutation = (options?: Partial<Options<DeleteNotificationDestinationData>>): UseMutationOptions<DeleteNotificationDestinationResponse, DefaultError, Options<DeleteNotificationDestinationData>> => {
const mutationOptions: UseMutationOptions<DeleteNotificationDestinationResponse, DefaultError, Options<DeleteNotificationDestinationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteNotificationDestination({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getNotificationDestinationQueryKey = (options: Options<GetNotificationDestinationData>) => createQueryKey("getNotificationDestination", options);
/**
* Get a notification destination by ID
*/
export const getNotificationDestinationOptions = (options: Options<GetNotificationDestinationData>) => queryOptions<GetNotificationDestinationResponse, DefaultError, GetNotificationDestinationResponse, ReturnType<typeof getNotificationDestinationQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getNotificationDestination({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getNotificationDestinationQueryKey(options)
});
/**
* Update a notification destination
*/
export const updateNotificationDestinationMutation = (options?: Partial<Options<UpdateNotificationDestinationData>>): UseMutationOptions<UpdateNotificationDestinationResponse, DefaultError, Options<UpdateNotificationDestinationData>> => {
const mutationOptions: UseMutationOptions<UpdateNotificationDestinationResponse, DefaultError, Options<UpdateNotificationDestinationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateNotificationDestination({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Test a notification destination by sending a test message
*/
export const testNotificationDestinationMutation = (options?: Partial<Options<TestNotificationDestinationData>>): UseMutationOptions<TestNotificationDestinationResponse, DefaultError, Options<TestNotificationDestinationData>> => {
const mutationOptions: UseMutationOptions<TestNotificationDestinationResponse, DefaultError, Options<TestNotificationDestinationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await testNotificationDestination({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey("getSystemInfo", options);
/**

View File

@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, 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, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, 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> & {
/**
@@ -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
*/
@@ -438,6 +452,98 @@ export const runForget = <ThrowOnError extends boolean = false>(options: Options
});
};
/**
* Get notification assignments for a backup schedule
*/
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => {
return (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/notifications',
...options
});
};
/**
* Update notification assignments for a backup schedule
*/
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => {
return (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/notifications',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
/**
* List all notification destinations
*/
export const listNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ListNotificationDestinationsData, ThrowOnError>) => {
return (options?.client ?? client).get<ListNotificationDestinationsResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations',
...options
});
};
/**
* Create a new notification destination
*/
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => {
return (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
/**
* Delete a notification destination
*/
export const deleteNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<DeleteNotificationDestinationData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}',
...options
});
};
/**
* Get a notification destination by ID
*/
export const getNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<GetNotificationDestinationData, ThrowOnError>) => {
return (options.client ?? client).get<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}',
...options
});
};
/**
* Update a notification destination
*/
export const updateNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationDestinationData, ThrowOnError>) => {
return (options.client ?? client).patch<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
/**
* Test a notification destination by sending a test message
*/
export const testNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<TestNotificationDestinationData, ThrowOnError>) => {
return (options.client ?? client).post<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}/test',
...options
});
};
/**
* Get system information including available capabilities
*/

View File

@@ -189,6 +189,7 @@ export type ListVolumesResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -279,6 +280,7 @@ export type CreateVolumeResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -422,6 +424,7 @@ export type GetVolumeResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -465,6 +468,7 @@ export type UpdateVolumeData = {
ssl?: boolean;
username?: string;
};
name?: string;
};
path: {
name: string;
@@ -522,6 +526,7 @@ export type UpdateVolumeResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -756,14 +761,24 @@ export type ListRepositoriesResponses = {
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';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number;
}>;
};
@@ -823,6 +838,15 @@ export type CreateRepositoryData = {
password?: string;
path?: string;
username?: string;
} | {
backend: 'sftp';
host: string;
path: string;
privateKey: string;
user: string;
port?: number;
customPassword?: string;
isExistingRepository?: boolean;
};
name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
@@ -952,20 +976,134 @@ export type GetRepositoryResponses = {
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';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number;
};
};
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 = {
body?: never;
path: {
@@ -1084,6 +1222,7 @@ export type RestoreSnapshotData = {
snapshotId: string;
delete?: boolean;
exclude?: Array<string>;
excludeXattr?: Array<string>;
include?: Array<string>;
};
path: {
@@ -1153,7 +1292,7 @@ export type ListBackupSchedulesResponses = {
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
@@ -1208,14 +1347,24 @@ export type ListBackupSchedulesResponses = {
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';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number;
};
repositoryId: string;
@@ -1267,6 +1416,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -1314,7 +1464,7 @@ export type CreateBackupScheduleResponses = {
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null;
repositoryId: string;
retentionPolicy: {
@@ -1375,7 +1525,7 @@ export type GetBackupScheduleResponses = {
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
@@ -1430,14 +1580,24 @@ export type GetBackupScheduleResponses = {
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';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number;
};
repositoryId: string;
@@ -1489,6 +1649,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -1537,7 +1698,7 @@ export type UpdateBackupScheduleResponses = {
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null;
repositoryId: string;
retentionPolicy: {
@@ -1578,7 +1739,7 @@ export type GetBackupScheduleForVolumeResponses = {
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
@@ -1633,14 +1794,24 @@ export type GetBackupScheduleForVolumeResponses = {
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';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number;
};
repositoryId: string;
@@ -1692,6 +1863,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -1769,6 +1941,608 @@ export type RunForgetResponses = {
export type RunForgetResponse = RunForgetResponses[keyof RunForgetResponses];
export type GetScheduleNotificationsData = {
body?: never;
path: {
scheduleId: string;
};
query?: never;
url: '/api/v1/backups/{scheduleId}/notifications';
};
export type GetScheduleNotificationsResponses = {
/**
* List of notification assignments for the schedule
*/
200: Array<{
createdAt: number;
destination: {
config: {
apiToken: string;
priority: -1 | 0 | 1;
type: 'pushover';
userKey: string;
devices?: string;
} | {
from: string;
password: string;
smtpHost: string;
smtpPort: number;
to: Array<string>;
type: 'email';
useTLS: boolean;
username: string;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
} | {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
iconEmoji?: string;
username?: string;
};
createdAt: number;
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
updatedAt: number;
};
destinationId: number;
notifyOnFailure: boolean;
notifyOnStart: boolean;
notifyOnSuccess: boolean;
scheduleId: number;
}>;
};
export type GetScheduleNotificationsResponse = GetScheduleNotificationsResponses[keyof GetScheduleNotificationsResponses];
export type UpdateScheduleNotificationsData = {
body?: {
assignments: Array<{
destinationId: number;
notifyOnFailure: boolean;
notifyOnStart: boolean;
notifyOnSuccess: boolean;
}>;
};
path: {
scheduleId: string;
};
query?: never;
url: '/api/v1/backups/{scheduleId}/notifications';
};
export type UpdateScheduleNotificationsResponses = {
/**
* Notification assignments updated successfully
*/
200: Array<{
createdAt: number;
destination: {
config: {
apiToken: string;
priority: -1 | 0 | 1;
type: 'pushover';
userKey: string;
devices?: string;
} | {
from: string;
password: string;
smtpHost: string;
smtpPort: number;
to: Array<string>;
type: 'email';
useTLS: boolean;
username: string;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
} | {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
iconEmoji?: string;
username?: string;
};
createdAt: number;
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
updatedAt: number;
};
destinationId: number;
notifyOnFailure: boolean;
notifyOnStart: boolean;
notifyOnSuccess: boolean;
scheduleId: number;
}>;
};
export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof UpdateScheduleNotificationsResponses];
export type ListNotificationDestinationsData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/notifications/destinations';
};
export type ListNotificationDestinationsResponses = {
/**
* A list of notification destinations
*/
200: Array<{
config: {
apiToken: string;
priority: -1 | 0 | 1;
type: 'pushover';
userKey: string;
devices?: string;
} | {
from: string;
password: string;
smtpHost: string;
smtpPort: number;
to: Array<string>;
type: 'email';
useTLS: boolean;
username: string;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
} | {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
iconEmoji?: string;
username?: string;
};
createdAt: number;
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
updatedAt: number;
}>;
};
export type ListNotificationDestinationsResponse = ListNotificationDestinationsResponses[keyof ListNotificationDestinationsResponses];
export type CreateNotificationDestinationData = {
body?: {
config: {
apiToken: string;
priority: -1 | 0 | 1;
type: 'pushover';
userKey: string;
devices?: string;
} | {
from: string;
password: string;
smtpHost: string;
smtpPort: number;
to: Array<string>;
type: 'email';
useTLS: boolean;
username: string;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
} | {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
iconEmoji?: string;
username?: string;
};
name: string;
};
path?: never;
query?: never;
url: '/api/v1/notifications/destinations';
};
export type CreateNotificationDestinationResponses = {
/**
* Notification destination created successfully
*/
201: {
config: {
apiToken: string;
priority: -1 | 0 | 1;
type: 'pushover';
userKey: string;
devices?: string;
} | {
from: string;
password: string;
smtpHost: string;
smtpPort: number;
to: Array<string>;
type: 'email';
useTLS: boolean;
username: string;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
} | {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
iconEmoji?: string;
username?: string;
};
createdAt: number;
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
updatedAt: number;
};
};
export type CreateNotificationDestinationResponse = CreateNotificationDestinationResponses[keyof CreateNotificationDestinationResponses];
export type DeleteNotificationDestinationData = {
body?: never;
path: {
id: string;
};
query?: never;
url: '/api/v1/notifications/destinations/{id}';
};
export type DeleteNotificationDestinationErrors = {
/**
* Notification destination not found
*/
404: unknown;
};
export type DeleteNotificationDestinationResponses = {
/**
* Notification destination deleted successfully
*/
200: {
message: string;
};
};
export type DeleteNotificationDestinationResponse = DeleteNotificationDestinationResponses[keyof DeleteNotificationDestinationResponses];
export type GetNotificationDestinationData = {
body?: never;
path: {
id: string;
};
query?: never;
url: '/api/v1/notifications/destinations/{id}';
};
export type GetNotificationDestinationErrors = {
/**
* Notification destination not found
*/
404: unknown;
};
export type GetNotificationDestinationResponses = {
/**
* Notification destination details
*/
200: {
config: {
apiToken: string;
priority: -1 | 0 | 1;
type: 'pushover';
userKey: string;
devices?: string;
} | {
from: string;
password: string;
smtpHost: string;
smtpPort: number;
to: Array<string>;
type: 'email';
useTLS: boolean;
username: string;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
} | {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
iconEmoji?: string;
username?: string;
};
createdAt: number;
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
updatedAt: number;
};
};
export type GetNotificationDestinationResponse = GetNotificationDestinationResponses[keyof GetNotificationDestinationResponses];
export type UpdateNotificationDestinationData = {
body?: {
config?: {
apiToken: string;
priority: -1 | 0 | 1;
type: 'pushover';
userKey: string;
devices?: string;
} | {
from: string;
password: string;
smtpHost: string;
smtpPort: number;
to: Array<string>;
type: 'email';
useTLS: boolean;
username: string;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
} | {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
iconEmoji?: string;
username?: string;
};
enabled?: boolean;
name?: string;
};
path: {
id: string;
};
query?: never;
url: '/api/v1/notifications/destinations/{id}';
};
export type UpdateNotificationDestinationErrors = {
/**
* Notification destination not found
*/
404: unknown;
};
export type UpdateNotificationDestinationResponses = {
/**
* Notification destination updated successfully
*/
200: {
config: {
apiToken: string;
priority: -1 | 0 | 1;
type: 'pushover';
userKey: string;
devices?: string;
} | {
from: string;
password: string;
smtpHost: string;
smtpPort: number;
to: Array<string>;
type: 'email';
useTLS: boolean;
username: string;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
} | {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
webhookUrl: string;
channel?: string;
iconEmoji?: string;
username?: string;
};
createdAt: number;
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
updatedAt: number;
};
};
export type UpdateNotificationDestinationResponse = UpdateNotificationDestinationResponses[keyof UpdateNotificationDestinationResponses];
export type TestNotificationDestinationData = {
body?: never;
path: {
id: string;
};
query?: never;
url: '/api/v1/notifications/destinations/{id}/test';
};
export type TestNotificationDestinationErrors = {
/**
* Notification destination not found
*/
404: unknown;
/**
* Cannot test disabled destination
*/
409: unknown;
/**
* Failed to send test notification
*/
500: unknown;
};
export type TestNotificationDestinationResponses = {
/**
* Test notification sent successfully
*/
200: {
success: boolean;
};
};
export type TestNotificationDestinationResponse = TestNotificationDestinationResponses[keyof TestNotificationDestinationResponses];
export type GetSystemInfoData = {
body?: never;
path?: never;

View File

@@ -1,4 +1,4 @@
import { CalendarClock, Database, HardDrive, Settings } from "lucide-react";
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
import { Link, NavLink } from "react-router";
import {
Sidebar,
@@ -32,6 +32,11 @@ const items = [
url: "/backups",
icon: CalendarClock,
},
{
title: "Notifications",
url: "/notifications",
icon: Bell,
},
{
title: "Settings",
url: "/settings",
@@ -46,11 +51,7 @@ export function AppSidebar() {
<Sidebar variant="inset" collapsible="icon" className="p-0">
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-[65px] flex-row items-center p-4">
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
<img
src="/images/zerobyte.png"
alt="Zerobyte Logo"
className={cn("h-8 w-8 flex-shrink-0 object-contain -ml-2")}
/>
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
<span
className={cn("text-base transition-all duration-200 -ml-1", {
"opacity-0 w-0 overflow-hidden ": state === "collapsed",

View File

@@ -1,36 +1,48 @@
import type { VolumeStatus } from "~/client/lib/types";
import { cn } from "~/client/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export const StatusDot = ({ status }: { status: VolumeStatus }) => {
type StatusVariant = "success" | "neutral" | "error" | "warning" | "info";
interface StatusDotProps {
variant: StatusVariant;
label: string;
animated?: boolean;
}
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
const statusMapping = {
mounted: {
success: {
color: "bg-green-500",
colorLight: "bg-emerald-400",
animated: true,
animated: animated ?? true,
},
unmounted: {
neutral: {
color: "bg-gray-500",
colorLight: "bg-gray-400",
animated: false,
animated: animated ?? false,
},
error: {
color: "bg-red-500",
colorLight: "bg-amber-700",
animated: true,
colorLight: "bg-red-400",
animated: animated ?? true,
},
unknown: {
warning: {
color: "bg-yellow-500",
colorLight: "bg-yellow-400",
animated: true,
animated: animated ?? true,
},
}[status];
info: {
color: "bg-blue-500",
colorLight: "bg-blue-400",
animated: animated ?? true,
},
}[variant];
return (
<Tooltip>
<TooltipTrigger>
<span className="relative flex size-3 mx-auto">
{statusMapping.animated && (
{statusMapping?.animated && (
<span
className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
@@ -38,11 +50,11 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
)}
/>
)}
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping?.color}`)} />
</span>
</TooltipTrigger>
<TooltipContent>
<p className="capitalize">{status}</p>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);

View File

@@ -3,7 +3,6 @@ import type { BackendType } from "~/schemas/volumes";
type VolumeIconProps = {
backend: BackendType;
size?: number;
};
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);
return (
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
<Icon size={size} />
<Icon className="h-4 w-4" />
{label}
</span>
);

View File

@@ -3,6 +3,7 @@ import type {
GetMeResponse,
GetRepositoryResponse,
GetVolumeResponse,
ListNotificationDestinationsResponse,
ListSnapshotsResponse,
} from "../api-client";
@@ -17,3 +18,5 @@ export type Repository = GetRepositoryResponse;
export type BackupSchedule = GetBackupScheduleResponse;
export type Snapshot = ListSnapshotsResponse[number];
export type NotificationDestination = ListNotificationDestinationsResponse[number];

View File

@@ -1,7 +1,4 @@
import { cn } from "~/client/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
type BackupStatus = "active" | "paused" | "error" | "in_progress";
import { StatusDot } from "~/client/components/status-dot";
export const BackupStatusDot = ({
enabled,
@@ -12,60 +9,22 @@ export const BackupStatusDot = ({
hasError?: boolean;
isInProgress?: boolean;
}) => {
let status: BackupStatus = "paused";
let variant: "success" | "neutral" | "error" | "info";
let label: string;
if (isInProgress) {
status = "in_progress";
variant = "info";
label = "Backup in progress";
} else if (hasError) {
status = "error";
variant = "error";
label = "Error";
} else if (enabled) {
status = "active";
variant = "success";
label = "Active";
} else {
variant = "neutral";
label = "Paused";
}
const statusMapping = {
active: {
color: "bg-green-500",
colorLight: "bg-emerald-400",
animated: true,
label: "Active",
},
paused: {
color: "bg-gray-500",
colorLight: "bg-gray-400",
animated: false,
label: "Paused",
},
error: {
color: "bg-red-500",
colorLight: "bg-red-400",
animated: true,
label: "Error",
},
in_progress: {
color: "bg-blue-500",
colorLight: "bg-blue-400",
animated: true,
label: "Backup in progress",
},
}[status];
return (
<Tooltip>
<TooltipTrigger>
<span className="relative flex size-3 mx-auto">
{statusMapping.animated && (
<span
className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
`${statusMapping.colorLight}`,
)}
/>
)}
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{statusMapping.label}</p>
</TooltipContent>
</Tooltip>
);
return <StatusDot variant={variant} label={label} />;
};

View File

@@ -0,0 +1,267 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Bell, Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Switch } from "~/client/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Badge } from "~/client/components/ui/badge";
import {
getScheduleNotificationsOptions,
updateScheduleNotificationsMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { NotificationDestination } from "~/client/lib/types";
type Props = {
scheduleId: number;
destinations: NotificationDestination[];
};
type NotificationAssignment = {
destinationId: number;
notifyOnStart: boolean;
notifyOnSuccess: boolean;
notifyOnFailure: boolean;
};
export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props) => {
const [assignments, setAssignments] = useState<Map<number, NotificationAssignment>>(new Map());
const [hasChanges, setHasChanges] = useState(false);
const [isAddingNew, setIsAddingNew] = useState(false);
const { data: currentAssignments } = useQuery({
...getScheduleNotificationsOptions({ path: { scheduleId: scheduleId.toString() } }),
});
const updateNotifications = useMutation({
...updateScheduleNotificationsMutation(),
onSuccess: () => {
toast.success("Notification settings saved successfully");
setHasChanges(false);
},
onError: (error) => {
toast.error("Failed to save notification settings", {
description: parseError(error)?.message,
});
},
});
useEffect(() => {
if (currentAssignments) {
const map = new Map<number, NotificationAssignment>();
for (const assignment of currentAssignments) {
map.set(assignment.destinationId, {
destinationId: assignment.destinationId,
notifyOnStart: assignment.notifyOnStart,
notifyOnSuccess: assignment.notifyOnSuccess,
notifyOnFailure: assignment.notifyOnFailure,
});
}
setAssignments(map);
}
}, [currentAssignments]);
const addDestination = (destinationId: string) => {
const id = Number.parseInt(destinationId, 10);
const newAssignments = new Map(assignments);
newAssignments.set(id, {
destinationId: id,
notifyOnStart: false,
notifyOnSuccess: false,
notifyOnFailure: true,
});
setAssignments(newAssignments);
setHasChanges(true);
setIsAddingNew(false);
};
const removeDestination = (destinationId: number) => {
const newAssignments = new Map(assignments);
newAssignments.delete(destinationId);
setAssignments(newAssignments);
setHasChanges(true);
};
const toggleEvent = (destinationId: number, event: "notifyOnStart" | "notifyOnSuccess" | "notifyOnFailure") => {
const assignment = assignments.get(destinationId);
if (!assignment) return;
const newAssignments = new Map(assignments);
newAssignments.set(destinationId, {
...assignment,
[event]: !assignment[event],
});
setAssignments(newAssignments);
setHasChanges(true);
};
const handleSave = () => {
const assignmentsList = Array.from(assignments.values());
updateNotifications.mutate({
path: { scheduleId: scheduleId.toString() },
body: {
assignments: assignmentsList,
},
});
};
const handleReset = () => {
if (currentAssignments) {
const map = new Map<number, NotificationAssignment>();
for (const assignment of currentAssignments) {
map.set(assignment.destinationId, {
destinationId: assignment.destinationId,
notifyOnStart: assignment.notifyOnStart,
notifyOnSuccess: assignment.notifyOnSuccess,
notifyOnFailure: assignment.notifyOnFailure,
});
}
setAssignments(map);
setHasChanges(false);
}
};
const getDestinationById = (id: number) => {
return destinations?.find((d) => d.id === id);
};
const availableDestinations = destinations?.filter((d) => !assignments.has(d.id)) || [];
const assignedDestinations = Array.from(assignments.keys())
.map((id) => getDestinationById(id))
.filter((d) => d !== undefined);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
<CardDescription>Configure which notifications to send for this backup schedule</CardDescription>
</div>
{!isAddingNew && availableDestinations.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
<Plus className="h-4 w-4 mr-2" />
Add notification
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isAddingNew && (
<div className="mb-6 flex items-center gap-2 max-w-md">
<Select onValueChange={addDestination}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a notification destination..." />
</SelectTrigger>
<SelectContent>
{availableDestinations.map((destination) => (
<SelectItem key={destination.id} value={destination.id.toString()}>
<div className="flex items-center gap-2">
<span>{destination.name}</span>
<span className="text-xs uppercase text-muted-foreground">({destination.type})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
Cancel
</Button>
</div>
)}
{assignedDestinations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Bell className="h-8 w-8 mb-2 opacity-20" />
<p className="text-sm">No notifications configured for this schedule.</p>
<p className="text-xs mt-1">Click "Add notification" to get started.</p>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Destination</TableHead>
<TableHead className="text-center w-[100px]">Start</TableHead>
<TableHead className="text-center w-[100px]">Success</TableHead>
<TableHead className="text-center w-[100px]">Failure</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignedDestinations.map((destination) => {
const assignment = assignments.get(destination.id);
if (!assignment) return null;
return (
<TableRow key={destination.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{destination.name}</span>
<Badge variant="outline" className="text-[10px] align-middle">
{destination.type}
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.notifyOnStart}
onCheckedChange={() => toggleEvent(destination.id, "notifyOnStart")}
/>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.notifyOnSuccess}
onCheckedChange={() => toggleEvent(destination.id, "notifyOnSuccess")}
/>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.notifyOnFailure}
onCheckedChange={() => toggleEvent(destination.id, "notifyOnFailure")}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => removeDestination(destination.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{hasChanges && (
<div className="flex gap-2 justify-end mt-4 pt-4">
<Button variant="outline" size="sm" onClick={handleReset}>
Cancel
</Button>
<Button variant="default" size="sm" onClick={handleSave} loading={updateNotifications.isPending}>
Save changes
</Button>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -164,10 +164,20 @@ export const ScheduleSummary = (props: Props) => {
{schedule.lastBackupStatus === "success" && "✓ Success"}
{schedule.lastBackupStatus === "error" && "✗ Error"}
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
{schedule.lastBackupStatus === "warning" && "! Warning"}
{!schedule.lastBackupStatus && "—"}
</p>
</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 && (
<div className="md:col-span-2 lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Error Details</p>

View File

@@ -1,11 +1,12 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react";
import { ChevronDown, FileIcon } from "lucide-react";
import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Label } from "~/client/components/ui/label";
import { Input } from "~/client/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
@@ -39,6 +40,8 @@ export const SnapshotFileBrowser = (props: Props) => {
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState("");
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
@@ -64,9 +67,11 @@ export const SnapshotFileBrowser = (props: Props) => {
const addBasePath = useCallback(
(displayPath: string): string => {
if (!volumeBasePath) return displayPath;
if (displayPath === "/") return volumeBasePath;
return `${volumeBasePath}${displayPath}`;
let vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (!vbp) return displayPath;
if (displayPath === "/") return vbp;
return `${vbp}${displayPath}`;
},
[volumeBasePath],
);
@@ -117,17 +122,23 @@ export const SnapshotFileBrowser = (props: Props) => {
const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path));
const excludeXattrArray = excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
restoreSnapshot({
path: { name: repositoryName },
body: {
snapshotId: snapshot.short_id,
include: includePaths,
delete: deleteExtraFiles,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
},
});
setShowRestoreDialog(false);
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]);
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]);
return (
<div className="space-y-4">
@@ -220,15 +231,46 @@ export const SnapshotFileBrowser = (props: Props) => {
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center space-x-2 py-4">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot?
</Label>
<div className="space-y-4">
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</Button>
{showAdvanced && (
<div className="mt-4 space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude Extended Attributes (Optional)
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot?
</Label>
</div>
</div>
)}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>

View File

@@ -29,7 +29,9 @@ import { ScheduleSummary } from "../components/schedule-summary";
import type { Route } from "./+types/backup-details";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline";
import { getBackupSchedule } from "~/client/api-client";
import { getBackupSchedule, listNotificationDestinations } from "~/client/api-client";
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
import { cn } from "~/client/lib/utils";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
@@ -49,11 +51,12 @@ export function meta(_: Route.MetaArgs) {
}
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
const notifs = await listNotificationDestinations();
if (!data) return redirect("/backups");
if (!schedule.data) return redirect("/backups");
return data;
return { schedule: schedule.data, notifs: notifs.data };
};
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
@@ -66,7 +69,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
initialData: loaderData,
initialData: loaderData.schedule,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
@@ -222,6 +225,9 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
setIsEditMode={setIsEditMode}
schedule={schedule}
/>
<div className={cn({ hidden: !loaderData.notifs?.length })}>
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
</div>
<SnapshotTimeline
loading={isLoading}
snapshots={snapshots ?? []}

View File

@@ -0,0 +1,651 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchema } from "~/schemas/notifications";
export const formSchema = type({
name: "2<=string<=32",
}).and(notificationConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type NotificationFormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: NotificationFormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<NotificationFormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
email: {
type: "email" as const,
smtpHost: "",
smtpPort: 587,
username: "",
password: "",
from: "",
to: [],
useTLS: true,
},
slack: {
type: "slack" as const,
webhookUrl: "",
},
discord: {
type: "discord" as const,
webhookUrl: "",
},
gotify: {
type: "gotify" as const,
serverUrl: "",
token: "",
priority: 5,
},
ntfy: {
type: "ntfy" as const,
topic: "",
priority: "default" as const,
},
pushover: {
type: "pushover" as const,
userKey: "",
apiToken: "",
priority: 0 as const,
},
custom: {
type: "custom" as const,
shoutrrrUrl: "",
},
};
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch } = form;
const watchedType = watch("type");
useEffect(() => {
if (!initialValues) {
form.reset({
name: form.getValues().name,
...defaultValuesForType[watchedType as keyof typeof defaultValuesForType],
});
}
}, [watchedType, form, initialValues]);
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="My notification"
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for this notification destination.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
disabled={mode === "update"}
>
<FormControl>
<SelectTrigger className={mode === "update" ? "bg-gray-50" : ""}>
<SelectValue placeholder="Select notification type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="email">Email (SMTP)</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="discord">Discord</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Choose the notification delivery method.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedType === "email" && (
<>
<FormField
control={form.control}
name="smtpHost"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Host</FormLabel>
<FormControl>
<Input {...field} placeholder="smtp.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input
{...field}
type="number"
placeholder="587"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} placeholder="user@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="from"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel>To Addresses</FormLabel>
<FormControl>
<Input
{...field}
placeholder="user@example.com, admin@example.com"
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
onChange={(e) => field.onChange(e.target.value.split(",").map((email) => email.trim()))}
/>
</FormControl>
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useTLS"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Use TLS</FormLabel>
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
</div>
</FormItem>
)}
/>
</>
)}
{watchedType === "slack" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
/>
</FormControl>
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconEmoji"
render={({ field }) => (
<FormItem>
<FormLabel>Icon Emoji (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder=":floppy_disk:" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "discord" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
</FormControl>
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatarUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/avatar.png" />
</FormControl>
<FormMessage />
</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>
)}
/>
</>
)}
{watchedType === "gotify" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormDescription>Application token from Gotify.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
max={10}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
<FormMessage />
</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>
)}
/>
</>
)}
{watchedType === "ntfy" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.example.com" />
</FormControl>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input {...field} placeholder="ironmount-backups" />
</FormControl>
<FormDescription>The ntfy topic name to publish to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<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>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="max">Max (5)</SelectItem>
<SelectItem value="high">High (4)</SelectItem>
<SelectItem value="default">Default (3)</SelectItem>
<SelectItem value="low">Low (2)</SelectItem>
<SelectItem value="min">Min (1)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "pushover" && (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
</FormControl>
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormDescription>Application API token from your Pushover application.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="devices"
render={({ field }) => (
<FormItem>
<FormLabel>Devices (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="iphone,android" />
</FormControl>
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={(value) => field.onChange(Number(value))}
defaultValue={String(field.value)}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="-1">Low (-1)</SelectItem>
<SelectItem value="0">Normal (0)</SelectItem>
<SelectItem value="1">High (1)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Message priority level.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "custom" && (
<FormField
control={form.control}
name="shoutrrrUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Shoutrrr URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
/>
</FormControl>
<FormDescription>
Direct Shoutrrr URL for power users. See&nbsp;
<a
href="https://shoutrrr.nickfedor.com/v0.12.0/services/overview/"
target="_blank"
rel="noopener noreferrer"
className="text-strong-accent hover:underline"
>
Shoutrrr documentation
</a>
&nbsp;for supported services and URL formats.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
);
};

View File

@@ -0,0 +1,83 @@
import { useMutation } from "@tanstack/react-query";
import { Bell } from "lucide-react";
import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { createNotificationDestinationMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-notification";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
export const handle = {
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Create Notification" },
{
name: "description",
content: "Create a new notification destination for backup alerts.",
},
];
}
export default function CreateNotification() {
const navigate = useNavigate();
const formId = useId();
const createNotification = useMutation({
...createNotificationDestinationMutation(),
onSuccess: () => {
toast.success("Notification destination created successfully");
navigate(`/notifications`);
},
});
const handleSubmit = (values: NotificationFormValues) => {
createNotification.mutate({ body: { name: values.name, config: values } });
};
return (
<div className="container mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Bell className="w-5 h-5 text-primary" />
</div>
<CardTitle>Create Notification Destination</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{createNotification.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to create notification destination:</strong>
<br />
{parseError(createNotification.error)?.message}
</AlertDescription>
</Alert>
)}
<CreateNotificationForm
mode="create"
formId={formId}
onSubmit={handleSubmit}
loading={createNotification.isPending}
/>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
Cancel
</Button>
<Button type="submit" form={formId} loading={createNotification.isPending}>
Create Destination
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router";
import { toast } from "sonner";
import { useState, useId } from "react";
import {
deleteNotificationDestinationMutation,
getNotificationDestinationOptions,
testNotificationDestinationMutation,
updateNotificationDestinationMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { parseError } from "~/client/lib/errors";
import { getNotificationDestination } from "~/client/api-client/sdk.gen";
import type { Route } from "./+types/notification-details";
import { cn } from "~/client/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Bell, TestTube2 } from "lucide-react";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Notifications", href: "/notifications" },
{ label: match.params.id },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Notification ${params.id}` },
{
name: "description",
content: "View and edit notification destination settings.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const destination = await getNotificationDestination({ path: { id: params.id ?? "" } });
if (destination.data) return destination.data;
return redirect("/notifications");
};
export default function NotificationDetailsPage({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const formId = useId();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useQuery({
...getNotificationDestinationOptions({ path: { id: String(loaderData.id) } }),
initialData: loaderData,
});
const deleteDestination = useMutation({
...deleteNotificationDestinationMutation(),
onSuccess: () => {
toast.success("Notification destination deleted successfully");
navigate("/notifications");
},
onError: (error) => {
toast.error("Failed to delete notification destination", {
description: parseError(error)?.message,
});
},
});
const updateDestination = useMutation({
...updateNotificationDestinationMutation(),
onSuccess: () => {
toast.success("Notification destination updated successfully");
},
onError: (error) => {
toast.error("Failed to update notification destination", {
description: parseError(error)?.message,
});
},
});
const testDestination = useMutation({
...testNotificationDestinationMutation(),
onSuccess: () => {
toast.success("Test notification sent successfully");
},
onError: (error) => {
toast.error("Failed to send test notification", {
description: parseError(error)?.message,
});
},
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteDestination.mutate({ path: { id: String(data.id) } });
};
const handleSubmit = (values: NotificationFormValues) => {
updateDestination.mutate({
path: { id: String(data.id) },
body: {
name: values.name,
config: values,
},
});
};
const handleTest = () => {
testDestination.mutate({ path: { id: String(data.id) } });
};
return (
<>
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
<span
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
"bg-green-500/10 text-green-500": data.enabled,
"bg-red-500/10 text-red-500": !data.enabled,
})}
>
{data.enabled ? "Enabled" : "Disabled"}
</span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1 capitalize">{data.type}</span>
</div>
<div className="flex gap-2">
<Button
onClick={handleTest}
disabled={testDestination.isPending || !data.enabled}
variant="outline"
loading={testDestination.isPending}
>
<TestTube2 className="h-4 w-4 mr-2" />
Test
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="destructive"
loading={deleteDestination.isPending}
>
Delete
</Button>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Bell className="w-5 h-5 text-primary" />
</div>
<CardTitle>{data.name}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{updateDestination.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to update notification destination:</strong>
<br />
{parseError(updateDestination.error)?.message}
</AlertDescription>
</Alert>
)}
<>
<CreateNotificationForm
mode="update"
formId={formId}
onSubmit={handleSubmit}
initialValues={data.config}
loading={updateDestination.isPending}
/>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="submit" form={formId} loading={updateDestination.isPending}>
Save Changes
</Button>
</div>
</>
</CardContent>
</Card>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Notification Destination</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the notification destination "{data.name}"? This action cannot be undone
and will remove this destination from all backup schedules.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,177 @@
import { useQuery } from "@tanstack/react-query";
import { Bell, Plus, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { EmptyState } from "~/client/components/empty-state";
import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import type { Route } from "./+types/notifications";
import { listNotificationDestinations } from "~/client/api-client";
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Notifications" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Notifications" },
{
name: "description",
content: "Manage notification destinations for backup alerts.",
},
];
}
export const clientLoader = async () => {
const result = await listNotificationDestinations();
if (result.data) return result.data;
return [];
};
export default function Notifications({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const clearFilters = () => {
setSearchQuery("");
setTypeFilter("");
setStatusFilter("");
};
const navigate = useNavigate();
const { data } = useQuery({
...listNotificationDestinationsOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredNotifications =
data?.filter((notification) => {
const matchesSearch = notification.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = !typeFilter || notification.type === typeFilter;
const matchesStatus =
!statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled);
return matchesSearch && matchesType && matchesStatus;
}) || [];
const hasNoNotifications = data.length === 0;
const hasNoFilteredNotifications = filteredNotifications.length === 0 && !hasNoNotifications;
if (hasNoNotifications) {
return (
<EmptyState
icon={Bell}
title="No notification destinations"
description="Set up notification channels to receive alerts when your backups complete or fail."
button={
<Button onClick={() => navigate("/notifications/create")}>
<Plus size={16} className="mr-2" />
Create Destination
</Button>
}
/>
);
}
return (
<Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
<Input
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
placeholder="Search destinations…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="discord">Discord</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
<SelectValue placeholder="All status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
{(searchQuery || typeFilter || statusFilter) && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
)}
</span>
<Button onClick={() => navigate("/notifications/create")}>
<Plus size={16} className="mr-2" />
Create Destination
</Button>
</div>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Type</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasNoFilteredNotifications ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No destinations match your filters.</p>
<Button onClick={clearFilters} variant="outline" size="sm">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
</div>
</TableCell>
</TableRow>
) : (
filteredNotifications.map((notification) => (
<TableRow
key={notification.id}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/notifications/${notification.id}`)}
>
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
<TableCell className="capitalize">{notification.type}</TableCell>
<TableCell className="text-center">
<StatusDot variant={notification.enabled ? "success" : "neutral"} label={notification.enabled ? "Enabled" : "Disabled"} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
<span>
<span className="text-strong-accent">{filteredNotifications.length}</span> destination
{filteredNotifications.length !== 1 ? "s" : ""}
</span>
</div>
</Card>
);
}

View File

@@ -52,12 +52,18 @@ export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
.map((s) => s.trim())
.filter(Boolean);
const excludeXattr = values.excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
restore.mutate({
path: { name },
body: {
snapshotId,
include: include && include.length > 0 ? include : undefined,
exclude: exclude && exclude.length > 0 ? exclude : undefined,
excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : undefined,
},
});
};

View File

@@ -1,5 +1,7 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
Form,
@@ -11,11 +13,13 @@ import {
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Button } from "~/client/components/ui/button";
const restoreSnapshotFormSchema = type({
path: "string?",
include: "string?",
exclude: "string?",
excludeXattr: "string?",
});
export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn;
@@ -27,12 +31,15 @@ type Props = {
};
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const form = useForm<RestoreSnapshotFormValues>({
resolver: arktypeResolver(restoreSnapshotFormSchema),
defaultValues: {
path: "",
include: "",
exclude: "",
excludeXattr: "",
},
});
@@ -90,6 +97,43 @@ export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
</FormItem>
)}
/>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced
<ChevronDown
size={16}
className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
/>
</Button>
{showAdvanced && (
<div className="mt-4">
<FormField
control={form.control}
name="excludeXattr"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Extended Attributes (Optional)</FormLabel>
<FormControl>
<Input placeholder="com.apple.metadata,user.custom" {...field} />
</FormControl>
<FormDescription>
Exclude specific extended attributes during restore (comma-separated)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
</form>
</Form>

View File

@@ -24,6 +24,7 @@ import { DockerTabContent } from "../tabs/docker";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { getVolume } from "~/client/api-client";
import type { VolumeStatus } from "~/client/lib/types";
import {
deleteVolumeMutation,
getVolumeOptions,
@@ -31,6 +32,16 @@ import {
unmountVolumeMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
const statusMap = {
mounted: "success" as const,
unmounted: "neutral" as const,
error: "error" as const,
unknown: "warning" as const,
};
return statusMap[status];
};
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [{ label: "Volumes", href: "/volumes" }, { label: match.params.name }],
};
@@ -124,9 +135,14 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
<span className="flex items-center gap-2">
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
<StatusDot
variant={getVolumeStatusVariant(volume.status)}
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
/>
&nbsp;
{volume.status[0].toUpperCase() + volume.status.slice(1)}
</span>
<VolumeIcon size={14} backend={volume?.config.backend} />
<VolumeIcon backend={volume?.config.backend} />
</div>
<div className="flex gap-4">
<Button

View File

@@ -13,6 +13,17 @@ import { VolumeIcon } from "~/client/components/volume-icon";
import type { Route } from "./+types/volumes";
import { listVolumes } from "~/client/api-client";
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import type { VolumeStatus } from "~/client/lib/types";
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
const statusMap = {
mounted: "success" as const,
unmounted: "neutral" as const,
error: "error" as const,
unknown: "warning" as const,
};
return statusMap[status];
};
export const handle = {
breadcrumb: () => [{ label: "Volumes" }],
@@ -157,7 +168,10 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="text-center">
<StatusDot status={volume.status} />
<StatusDot
variant={getVolumeStatusVariant(volume.status)}
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
/>
</TableCell>
</TableRow>
))

View File

@@ -16,17 +16,17 @@ export const DockerTabContent = ({ volume }: Props) => {
services: {
nginx: {
image: "nginx:latest",
volumes: [`im-${volume.name}:/path/in/container`],
volumes: [`zb-${volume.shortId}:/path/in/container`],
},
},
volumes: {
[`im-${volume.name}`]: {
[`zb-${volume.shortId}`]: {
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 {
data: containersData,

View File

@@ -0,0 +1,23 @@
CREATE TABLE `backup_schedule_notifications_table` (
`schedule_id` integer NOT NULL,
`destination_id` integer NOT NULL,
`notify_on_start` integer DEFAULT false NOT NULL,
`notify_on_success` integer DEFAULT false NOT NULL,
`notify_on_failure` integer DEFAULT true NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
PRIMARY KEY(`schedule_id`, `destination_id`),
FOREIGN KEY (`schedule_id`) REFERENCES `backup_schedules_table`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`destination_id`) REFERENCES `notification_destinations_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `notification_destinations_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`type` text NOT NULL,
`config` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `notification_destinations_table_name_unique` ON `notification_destinations_table` (`name`);

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,620 @@
{
"version": "6",
"dialect": "sqlite",
"id": "67552135-fa49-478f-9333-107d3dbd7610",
"prevId": "17f234ba-4123-4951-a39f-6002d537435f",
"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
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"repositories_table_name_unique": {
"name": "repositories_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"has_downloaded_restic_password": {
"name": "has_downloaded_restic_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

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

@@ -1,83 +1,118 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1755765658194,
"tag": "0000_known_madelyne_pryor",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1755775437391,
"tag": "0001_far_frank_castle",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1756930554198,
"tag": "0002_cheerful_randall",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1758653407064,
"tag": "0003_mature_hellcat",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1758961535488,
"tag": "0004_wealthy_tomas",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1759416698274,
"tag": "0005_simple_alice",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1760734377440,
"tag": "0006_secret_micromacro",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1761224911352,
"tag": "0007_watery_sersi",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1761414054481,
"tag": "0008_silent_lady_bullseye",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1762095226041,
"tag": "0009_little_adam_warlock",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1762610065889,
"tag": "0010_perfect_proemial_gods",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1755765658194,
"tag": "0000_known_madelyne_pryor",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1755775437391,
"tag": "0001_far_frank_castle",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1756930554198,
"tag": "0002_cheerful_randall",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1758653407064,
"tag": "0003_mature_hellcat",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1758961535488,
"tag": "0004_wealthy_tomas",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1759416698274,
"tag": "0005_simple_alice",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1760734377440,
"tag": "0006_secret_micromacro",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1761224911352,
"tag": "0007_watery_sersi",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1761414054481,
"tag": "0008_silent_lady_bullseye",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1762095226041,
"tag": "0009_little_adam_warlock",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1762610065889,
"tag": "0010_perfect_proemial_gods",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1763644043601,
"tag": "0011_familiar_stone_men",
"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

@@ -16,6 +16,9 @@ export default [
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),
route("settings", "./client/modules/settings/routes/settings.tsx"),
]),
] satisfies RouteConfig;

View File

@@ -0,0 +1,89 @@
import { type } from "arktype";
export const NOTIFICATION_TYPES = {
email: "email",
slack: "slack",
discord: "discord",
gotify: "gotify",
ntfy: "ntfy",
pushover: "pushover",
custom: "custom",
} as const;
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
export const emailNotificationConfigSchema = type({
type: "'email'",
smtpHost: "string",
smtpPort: "1 <= number <= 65535",
username: "string",
password: "string",
from: "string",
to: "string[]",
useTLS: "boolean",
});
export const slackNotificationConfigSchema = type({
type: "'slack'",
webhookUrl: "string",
channel: "string?",
username: "string?",
iconEmoji: "string?",
});
export const discordNotificationConfigSchema = type({
type: "'discord'",
webhookUrl: "string",
username: "string?",
avatarUrl: "string?",
threadId: "string?",
});
export const gotifyNotificationConfigSchema = type({
type: "'gotify'",
serverUrl: "string",
token: "string",
path: "string?",
priority: "0 <= number <= 10",
});
export const ntfyNotificationConfigSchema = type({
type: "'ntfy'",
serverUrl: "string?",
topic: "string",
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
username: "string?",
password: "string?",
});
export const pushoverNotificationConfigSchema = type({
type: "'pushover'",
userKey: "string",
apiToken: "string",
devices: "string?",
priority: "-1 | 0 | 1",
});
export const customNotificationConfigSchema = type({
type: "'custom'",
shoutrrrUrl: "string",
});
export const notificationConfigSchema = emailNotificationConfigSchema
.or(slackNotificationConfigSchema)
.or(discordNotificationConfigSchema)
.or(gotifyNotificationConfigSchema)
.or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema)
.or(customNotificationConfigSchema);
export type NotificationConfig = typeof notificationConfigSchema.infer;
export const NOTIFICATION_EVENTS = {
start: "start",
success: "success",
failure: "failure",
warning: "warning",
} as const;
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 RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
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;
volumeName: string;
repositoryName: string;
status: "success" | "error" | "stopped";
status: "success" | "error" | "stopped" | "warning";
}) => void;
"volume:mounted": (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 });
const sqlite = new Database(DATABASE_URL);
sqlite.run("PRAGMA foreign_keys = ON;");
export const db = drizzle({ client: sqlite, schema });
export const runDbMigrations = () => {
@@ -23,4 +21,6 @@ export const runDbMigrations = () => {
}
migrate(db, { migrationsFolder });
sqlite.run("PRAGMA foreign_keys = ON;");
};

View File

@@ -1,13 +1,15 @@
import { relations, sql } from "drizzle-orm";
import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { int, integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core";
import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic";
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
/**
* Volumes Table
*/
export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
@@ -47,6 +49,7 @@ export type Session = typeof sessionsTable.$inferSelect;
*/
export const repositoriesTable = sqliteTable("repositories_table", {
id: text().primaryKey(),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
type: text().$type<RepositoryBackend>().notNull(),
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
@@ -84,13 +87,13 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
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"),
nextBackupAt: int("next_backup_at", { mode: "number" }),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one }) => ({
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, {
fields: [backupSchedulesTable.volumeId],
references: [volumesTable.id],
@@ -99,5 +102,66 @@ export const backupScheduleRelations = relations(backupSchedulesTable, ({ one })
fields: [backupSchedulesTable.repositoryId],
references: [repositoriesTable.id],
}),
notifications: many(backupScheduleNotificationsTable),
}));
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
/**
* Notification Destinations Table
*/
export const notificationDestinationsTable = sqliteTable("notification_destinations_table", {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull().unique(),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
type: text().$type<NotificationType>().notNull(),
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
schedules: many(backupScheduleNotificationsTable),
}));
export type NotificationDestination = typeof notificationDestinationsTable.$inferSelect;
/**
* Backup Schedule Notifications Junction Table (Many-to-Many)
*/
export const backupScheduleNotificationsTable = sqliteTable(
"backup_schedule_notifications_table",
{
scheduleId: int("schedule_id")
.notNull()
.references(() => backupSchedulesTable.id, { onDelete: "cascade" }),
destinationId: int("destination_id")
.notNull()
.references(() => notificationDestinationsTable.id, { onDelete: "cascade" }),
notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false),
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
},
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
);
export const backupScheduleNotificationRelations = relations(backupScheduleNotificationsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleNotificationsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
destination: one(notificationDestinationsTable, {
fields: [backupScheduleNotificationsTable.destinationId],
references: [notificationDestinationsTable.id],
}),
}));
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,15 +10,18 @@ import { authController } from "./modules/auth/auth.controller";
import { requireAuth } from "./modules/auth/auth.middleware";
import { driverController } from "./modules/driver/driver.controller";
import { startup } from "./modules/lifecycle/startup";
import { migrateToShortIds } from "./modules/lifecycle/migration";
import { repositoriesController } from "./modules/repositories/repositories.controller";
import { systemController } from "./modules/system/system.controller";
import { volumeController } from "./modules/volumes/volume.controller";
import { backupScheduleController } from "./modules/backups/backups.controller";
import { eventsController } from "./modules/events/events.controller";
import { notificationsController } from "./modules/notifications/notifications.controller";
import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger";
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) =>
openAPIRouteHandler(app, {
@@ -46,6 +49,7 @@ const app = new Hono()
.route("/api/v1/volumes", volumeController.use(requireAuth))
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
.route("/api/v1/notifications", notificationsController.use(requireAuth))
.route("/api/v1/system", systemController.use(requireAuth))
.route("/api/v1/events", eventsController.use(requireAuth));
@@ -66,6 +70,9 @@ app.onError((err, c) => {
runDbMigrations();
await migrateToShortIds();
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
const { docker } = await getCapabilities();
if (docker) {

View File

@@ -23,6 +23,14 @@ import {
type UpdateBackupScheduleDto,
} from "./backups.dto";
import { backupsService } from "./backups.service";
import {
getScheduleNotificationsDto,
updateScheduleNotificationsBody,
updateScheduleNotificationsDto,
type GetScheduleNotificationsDto,
type UpdateScheduleNotificationsDto,
} from "../notifications/notifications.dto";
import { notificationsService } from "../notifications/notifications.service";
export const backupScheduleController = new Hono()
.get("/", listBackupSchedulesDto, async (c) => {
@@ -87,4 +95,22 @@ export const backupScheduleController = new Hono()
await backupsService.runForget(Number(scheduleId));
return c.json<RunForgetDto>({ success: true }, 200);
});
})
.get("/:scheduleId/notifications", getScheduleNotificationsDto, async (c) => {
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
const assignments = await notificationsService.getScheduleNotifications(scheduleId);
return c.json<GetScheduleNotificationsDto>(assignments, 200);
})
.put(
"/:scheduleId/notifications",
updateScheduleNotificationsDto,
validator("json", updateScheduleNotificationsBody),
async (c) => {
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
const body = c.req.valid("json");
const assignments = await notificationsService.updateScheduleNotifications(scheduleId, body.assignments);
return c.json<UpdateScheduleNotificationsDto>(assignments, 200);
},
);

View File

@@ -25,7 +25,7 @@ const backupScheduleSchema = type({
excludePatterns: "string[] | null",
includePatterns: "string[] | null",
lastBackupAt: "number | null",
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
lastBackupError: "string | null",
nextBackupAt: "number | null",
createdAt: "number",

View File

@@ -10,6 +10,7 @@ import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors";
import { serverEvents } from "../../core/events";
import { notificationsService } from "../notifications/notifications.service";
const runningBackups = new Map<number, AbortController>();
@@ -195,6 +196,15 @@ const executeBackup = async (scheduleId: number, manual = false) => {
repositoryName: repository.name,
});
notificationsService
.sendBackupNotification(scheduleId, "start", {
volumeName: volume.name,
repositoryName: repository.name,
})
.catch((error) => {
logger.error(`Failed to send backup start notification: ${toMessage(error)}`);
});
const nextBackupAt = calculateNextRun(schedule.cronExpression);
await db
@@ -226,8 +236,9 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.include = schedule.includePatterns;
}
await restic.backup(repository.config, volumePath, {
const { exitCode } = await restic.backup(repository.config, volumePath, {
...backupOptions,
compressionMode: repository.compressionMode ?? "auto",
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
@@ -247,21 +258,34 @@ const executeBackup = async (scheduleId: number, manual = false) => {
.update(backupSchedulesTable)
.set({
lastBackupAt: Date.now(),
lastBackupStatus: "success",
lastBackupStatus: exitCode === 0 ? "success" : "warning",
lastBackupError: null,
nextBackupAt: nextBackupAt,
updatedAt: Date.now(),
})
.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", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
status: "success",
status: exitCode === 0 ? "success" : "warning",
});
notificationsService
.sendBackupNotification(scheduleId, exitCode === 0 ? "success" : "warning", {
volumeName: volume.name,
repositoryName: repository.name,
})
.catch((error) => {
logger.error(`Failed to send backup success notification: ${toMessage(error)}`);
});
} catch (error) {
logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`);
@@ -282,6 +306,16 @@ const executeBackup = async (scheduleId: number, manual = false) => {
status: "error",
});
notificationsService
.sendBackupNotification(scheduleId, "failure", {
volumeName: volume.name,
repositoryName: repository.name,
error: toMessage(error),
})
.catch((notifError) => {
logger.error(`Failed to send backup failure notification: ${toMessage(notifError)}`);
});
throw error;
} finally {
runningBackups.delete(scheduleId);

View File

@@ -1,6 +1,9 @@
import { Hono } from "hono";
import { volumeService } from "../volumes/volume.service";
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()
.post("/VolumeDriver.Capabilities", (c) => {
@@ -30,10 +33,18 @@ export const driverController = new Hono()
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({
Mountpoint: getVolumePath(volumeName),
Mountpoint: getVolumePath(volume),
});
})
.post("/VolumeDriver.Unmount", (c) => {
@@ -48,7 +59,15 @@ export const driverController = new Hono()
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({
Mountpoint: getVolumePath(volume),
@@ -61,11 +80,19 @@ export const driverController = new Hono()
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({
Volume: {
Name: `zb-${volume.name}`,
Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume),
Status: {},
},
@@ -76,7 +103,7 @@ export const driverController = new Hono()
const volumes = await volumeService.listVolumes();
const res = volumes.map((volume) => ({
Name: `zb-${volume.name}`,
Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume),
Status: {},
}));

View File

@@ -41,7 +41,7 @@ export const eventsController = new Hono().get("/", (c) => {
scheduleId: number;
volumeName: string;
repositoryName: string;
status: "success" | "error" | "stopped";
status: "success" | "error" | "stopped" | "warning";
}) => {
stream.writeSSE({
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,193 @@
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.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

@@ -0,0 +1,5 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildCustomShoutrrrUrl(config: Extract<NotificationConfig, { type: "custom" }>): string {
return config.shoutrrrUrl;
}

View File

@@ -0,0 +1,31 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { type: "discord" }>): string {
const url = new URL(config.webhookUrl);
const pathParts = url.pathname.split("/").filter(Boolean);
if (pathParts.length < 4 || pathParts[0] !== "api" || pathParts[1] !== "webhooks") {
throw new Error("Invalid Discord webhook URL format");
}
const [, , webhookId, webhookToken] = pathParts;
let shoutrrrUrl = `discord://${webhookToken}@${webhookId}`;
const params = new URLSearchParams();
if (config.username) {
params.append("username", config.username);
}
if (config.avatarUrl) {
params.append("avatarurl", config.avatarUrl);
}
if (config.threadId) {
params.append("thread_id", config.threadId);
}
if (params.toString()) {
shoutrrrUrl += `?${params.toString()}`;
}
return shoutrrrUrl;
}

View File

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

View File

@@ -0,0 +1,16 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { type: "gotify" }>): string {
const url = new URL(config.serverUrl);
const hostname = url.hostname;
const port = url.port ? `:${url.port}` : "";
const path = config.path ? `/${config.path.replace(/^\/+|\/+$/g, "")}` : "";
let shoutrrrUrl = `gotify://${hostname}${port}${path}/${config.token}`;
if (config.priority !== undefined) {
shoutrrrUrl += `?priority=${config.priority}`;
}
return shoutrrrUrl;
}

View File

@@ -0,0 +1,32 @@
import type { NotificationConfig } from "~/schemas/notifications";
import { buildEmailShoutrrrUrl } from "./email";
import { buildSlackShoutrrrUrl } from "./slack";
import { buildDiscordShoutrrrUrl } from "./discord";
import { buildGotifyShoutrrrUrl } from "./gotify";
import { buildNtfyShoutrrrUrl } from "./ntfy";
import { buildPushoverShoutrrrUrl } from "./pushover";
import { buildCustomShoutrrrUrl } from "./custom";
export function buildShoutrrrUrl(config: NotificationConfig): string {
switch (config.type) {
case "email":
return buildEmailShoutrrrUrl(config);
case "slack":
return buildSlackShoutrrrUrl(config);
case "discord":
return buildDiscordShoutrrrUrl(config);
case "gotify":
return buildGotifyShoutrrrUrl(config);
case "ntfy":
return buildNtfyShoutrrrUrl(config);
case "pushover":
return buildPushoverShoutrrrUrl(config);
case "custom":
return buildCustomShoutrrrUrl(config);
default: {
// TypeScript exhaustiveness check
const _exhaustive: never = config;
throw new Error(`Unsupported notification type: ${(_exhaustive as NotificationConfig).type}`);
}
}
}

View File

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

View File

@@ -0,0 +1,22 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { type: "pushover" }>): string {
const params = new URLSearchParams();
if (config.devices) {
params.append("devices", config.devices);
}
if (config.priority !== undefined) {
params.append("priority", config.priority.toString());
}
const queryString = params.toString();
let shoutrrrUrl = `pushover://shoutrrr:${config.apiToken}@${config.userKey}/`;
if (queryString) {
shoutrrrUrl += `?${queryString}`;
}
return shoutrrrUrl;
}

View File

@@ -0,0 +1,31 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type: "slack" }>): string {
const url = new URL(config.webhookUrl);
const pathParts = url.pathname.split("/").filter(Boolean);
if (pathParts.length < 4 || pathParts[0] !== "services") {
throw new Error("Invalid Slack webhook URL format");
}
const [, tokenA, tokenB, tokenC] = pathParts;
let shoutrrrUrl = `slack://hook:${tokenA}-${tokenB}-${tokenC}@webhook`;
const params = new URLSearchParams();
if (config.channel) {
params.append("channel", config.channel);
}
if (config.username) {
params.append("username", config.username);
}
if (config.iconEmoji) {
params.append("icon_emoji", config.iconEmoji);
}
if (params.toString()) {
shoutrrrUrl += `?${params.toString()}`;
}
return shoutrrrUrl;
}

View File

@@ -0,0 +1,51 @@
import { Hono } from "hono";
import { validator } from "hono-openapi";
import {
createDestinationBody,
createDestinationDto,
deleteDestinationDto,
getDestinationDto,
listDestinationsDto,
testDestinationDto,
updateDestinationBody,
updateDestinationDto,
type CreateDestinationDto,
type DeleteDestinationDto,
type GetDestinationDto,
type ListDestinationsDto,
type TestDestinationDto,
type UpdateDestinationDto,
} from "./notifications.dto";
import { notificationsService } from "./notifications.service";
export const notificationsController = new Hono()
.get("/destinations", listDestinationsDto, async (c) => {
const destinations = await notificationsService.listDestinations();
return c.json<ListDestinationsDto>(destinations, 200);
})
.post("/destinations", createDestinationDto, validator("json", createDestinationBody), async (c) => {
const body = c.req.valid("json");
const destination = await notificationsService.createDestination(body.name, body.config);
return c.json<CreateDestinationDto>(destination, 201);
})
.get("/destinations/:id", getDestinationDto, async (c) => {
const id = Number.parseInt(c.req.param("id"), 10);
const destination = await notificationsService.getDestination(id);
return c.json<GetDestinationDto>(destination, 200);
})
.patch("/destinations/:id", updateDestinationDto, validator("json", updateDestinationBody), async (c) => {
const id = Number.parseInt(c.req.param("id"), 10);
const body = c.req.valid("json");
const destination = await notificationsService.updateDestination(id, body);
return c.json<UpdateDestinationDto>(destination, 200);
})
.delete("/destinations/:id", deleteDestinationDto, async (c) => {
const id = Number.parseInt(c.req.param("id"), 10);
await notificationsService.deleteDestination(id);
return c.json<DeleteDestinationDto>({ message: "Notification destination deleted" }, 200);
})
.post("/destinations/:id/test", testDestinationDto, async (c) => {
const id = Number.parseInt(c.req.param("id"), 10);
const result = await notificationsService.testDestination(id);
return c.json<TestDestinationDto>(result, 200);
});

View File

@@ -0,0 +1,251 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
import { NOTIFICATION_TYPES, notificationConfigSchema } from "~/schemas/notifications";
/**
* Notification Destination Schema
*/
export const notificationDestinationSchema = type({
id: "number",
name: "string",
enabled: "boolean",
type: type.valueOf(NOTIFICATION_TYPES),
config: notificationConfigSchema,
createdAt: "number",
updatedAt: "number",
});
export type NotificationDestinationDto = typeof notificationDestinationSchema.infer;
/**
* List all notification destinations
*/
export const listDestinationsResponse = notificationDestinationSchema.array();
export type ListDestinationsDto = typeof listDestinationsResponse.infer;
export const listDestinationsDto = describeRoute({
description: "List all notification destinations",
tags: ["Notifications"],
operationId: "listNotificationDestinations",
responses: {
200: {
description: "A list of notification destinations",
content: {
"application/json": {
schema: resolver(listDestinationsResponse),
},
},
},
},
});
/**
* Create a new notification destination
*/
export const createDestinationBody = type({
name: "string",
config: notificationConfigSchema,
});
export const createDestinationResponse = notificationDestinationSchema;
export type CreateDestinationDto = typeof createDestinationResponse.infer;
export const createDestinationDto = describeRoute({
description: "Create a new notification destination",
operationId: "createNotificationDestination",
tags: ["Notifications"],
responses: {
201: {
description: "Notification destination created successfully",
content: {
"application/json": {
schema: resolver(createDestinationResponse),
},
},
},
},
});
/**
* Get a single notification destination
*/
export const getDestinationResponse = notificationDestinationSchema;
export type GetDestinationDto = typeof getDestinationResponse.infer;
export const getDestinationDto = describeRoute({
description: "Get a notification destination by ID",
operationId: "getNotificationDestination",
tags: ["Notifications"],
responses: {
200: {
description: "Notification destination details",
content: {
"application/json": {
schema: resolver(getDestinationResponse),
},
},
},
404: {
description: "Notification destination not found",
},
},
});
/**
* Update a notification destination
*/
export const updateDestinationBody = type({
"name?": "string",
"enabled?": "boolean",
"config?": notificationConfigSchema,
});
export const updateDestinationResponse = notificationDestinationSchema;
export type UpdateDestinationDto = typeof updateDestinationResponse.infer;
export const updateDestinationDto = describeRoute({
description: "Update a notification destination",
operationId: "updateNotificationDestination",
tags: ["Notifications"],
responses: {
200: {
description: "Notification destination updated successfully",
content: {
"application/json": {
schema: resolver(updateDestinationResponse),
},
},
},
404: {
description: "Notification destination not found",
},
},
});
/**
* Delete a notification destination
*/
export const deleteDestinationResponse = type({
message: "string",
});
export type DeleteDestinationDto = typeof deleteDestinationResponse.infer;
export const deleteDestinationDto = describeRoute({
description: "Delete a notification destination",
operationId: "deleteNotificationDestination",
tags: ["Notifications"],
responses: {
200: {
description: "Notification destination deleted successfully",
content: {
"application/json": {
schema: resolver(deleteDestinationResponse),
},
},
},
404: {
description: "Notification destination not found",
},
},
});
/**
* Test a notification destination
*/
export const testDestinationResponse = type({
success: "boolean",
});
export type TestDestinationDto = typeof testDestinationResponse.infer;
export const testDestinationDto = describeRoute({
description: "Test a notification destination by sending a test message",
operationId: "testNotificationDestination",
tags: ["Notifications"],
responses: {
200: {
description: "Test notification sent successfully",
content: {
"application/json": {
schema: resolver(testDestinationResponse),
},
},
},
404: {
description: "Notification destination not found",
},
409: {
description: "Cannot test disabled destination",
},
500: {
description: "Failed to send test notification",
},
},
});
/**
* Backup Schedule Notification Assignment Schema
*/
export const scheduleNotificationAssignmentSchema = type({
scheduleId: "number",
destinationId: "number",
notifyOnStart: "boolean",
notifyOnSuccess: "boolean",
notifyOnFailure: "boolean",
createdAt: "number",
destination: notificationDestinationSchema,
});
export type ScheduleNotificationAssignmentDto = typeof scheduleNotificationAssignmentSchema.infer;
/**
* Get notifications for a backup schedule
*/
export const getScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
export type GetScheduleNotificationsDto = typeof getScheduleNotificationsResponse.infer;
export const getScheduleNotificationsDto = describeRoute({
description: "Get notification assignments for a backup schedule",
operationId: "getScheduleNotifications",
tags: ["Backups", "Notifications"],
responses: {
200: {
description: "List of notification assignments for the schedule",
content: {
"application/json": {
schema: resolver(getScheduleNotificationsResponse),
},
},
},
},
});
/**
* Update notifications for a backup schedule
*/
export const updateScheduleNotificationsBody = type({
assignments: type({
destinationId: "number",
notifyOnStart: "boolean",
notifyOnSuccess: "boolean",
notifyOnFailure: "boolean",
}).array(),
});
export const updateScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
export type UpdateScheduleNotificationsDto = typeof updateScheduleNotificationsResponse.infer;
export const updateScheduleNotificationsDto = describeRoute({
description: "Update notification assignments for a backup schedule",
operationId: "updateScheduleNotifications",
tags: ["Backups", "Notifications"],
responses: {
200: {
description: "Notification assignments updated successfully",
content: {
"application/json": {
schema: resolver(updateScheduleNotificationsResponse),
},
},
},
},
});

View File

@@ -0,0 +1,433 @@
import { eq, and, ne } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { db } from "../../db/db";
import {
notificationDestinationsTable,
backupScheduleNotificationsTable,
type NotificationDestination,
} from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger";
import { sendNotification } from "../../utils/shoutrrr";
import { buildShoutrrrUrl } from "./builders";
import type { NotificationConfig, NotificationEvent } from "~/schemas/notifications";
import { toMessage } from "../../utils/errors";
const listDestinations = async () => {
const destinations = await db.query.notificationDestinationsTable.findMany({
orderBy: (destinations, { asc }) => [asc(destinations.name)],
});
return destinations;
};
const getDestination = async (id: number) => {
const destination = await db.query.notificationDestinationsTable.findFirst({
where: eq(notificationDestinationsTable.id, id),
});
if (!destination) {
throw new NotFoundError("Notification destination not found");
}
return destination;
};
async function encryptSensitiveFields(config: NotificationConfig): Promise<NotificationConfig> {
switch (config.type) {
case "email":
return {
...config,
password: await cryptoUtils.encrypt(config.password),
};
case "slack":
return {
...config,
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
};
case "discord":
return {
...config,
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
};
case "gotify":
return {
...config,
token: await cryptoUtils.encrypt(config.token),
};
case "ntfy":
return {
...config,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
};
case "pushover":
return {
...config,
apiToken: await cryptoUtils.encrypt(config.apiToken),
};
case "custom":
return {
...config,
shoutrrrUrl: await cryptoUtils.encrypt(config.shoutrrrUrl),
};
default:
return config;
}
}
async function decryptSensitiveFields(config: NotificationConfig): Promise<NotificationConfig> {
switch (config.type) {
case "email":
return {
...config,
password: await cryptoUtils.decrypt(config.password),
};
case "slack":
return {
...config,
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
};
case "discord":
return {
...config,
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
};
case "gotify":
return {
...config,
token: await cryptoUtils.decrypt(config.token),
};
case "ntfy":
return {
...config,
password: config.password ? await cryptoUtils.decrypt(config.password) : undefined,
};
case "pushover":
return {
...config,
apiToken: await cryptoUtils.decrypt(config.apiToken),
};
case "custom":
return {
...config,
shoutrrrUrl: await cryptoUtils.decrypt(config.shoutrrrUrl),
};
default:
return config;
}
}
const createDestination = async (name: string, config: NotificationConfig) => {
const slug = slugify(name, { lower: true, strict: true });
const existing = await db.query.notificationDestinationsTable.findFirst({
where: eq(notificationDestinationsTable.name, slug),
});
if (existing) {
throw new ConflictError("Notification destination with this name already exists");
}
const encryptedConfig = await encryptSensitiveFields(config);
const [created] = await db
.insert(notificationDestinationsTable)
.values({
name: slug,
type: config.type,
config: encryptedConfig,
})
.returning();
if (!created) {
throw new InternalServerError("Failed to create notification destination");
}
return created;
};
const updateDestination = async (
id: number,
updates: { name?: string; enabled?: boolean; config?: NotificationConfig },
) => {
const existing = await getDestination(id);
if (!existing) {
throw new NotFoundError("Notification destination not found");
}
const updateData: Partial<NotificationDestination> = {
updatedAt: Math.floor(Date.now() / 1000),
};
if (updates.name !== undefined) {
const slug = slugify(updates.name, { lower: true, strict: true });
const conflict = await db.query.notificationDestinationsTable.findFirst({
where: and(eq(notificationDestinationsTable.name, slug), ne(notificationDestinationsTable.id, id)),
});
if (conflict) {
throw new ConflictError("Notification destination with this name already exists");
}
updateData.name = slug;
}
if (updates.enabled !== undefined) {
updateData.enabled = updates.enabled;
}
if (updates.config !== undefined) {
const encryptedConfig = await encryptSensitiveFields(updates.config);
updateData.config = encryptedConfig;
updateData.type = updates.config.type;
}
const [updated] = await db
.update(notificationDestinationsTable)
.set(updateData)
.where(eq(notificationDestinationsTable.id, id))
.returning();
if (!updated) {
throw new InternalServerError("Failed to update notification destination");
}
return updated;
};
const deleteDestination = async (id: number) => {
await db.delete(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id));
};
const testDestination = async (id: number) => {
const destination = await getDestination(id);
if (!destination.enabled) {
throw new ConflictError("Cannot test disabled notification destination");
}
const decryptedConfig = await decryptSensitiveFields(destination.config);
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
console.log("Testing notification with Shoutrrr URL:", shoutrrrUrl);
const result = await sendNotification({
shoutrrrUrl,
title: "Zerobyte Test Notification",
body: `This is a test notification from Zerobyte for destination: ${destination.name}`,
});
if (!result.success) {
throw new InternalServerError(`Failed to send test notification: ${result.error}`);
}
return { success: true };
};
const getScheduleNotifications = async (scheduleId: number) => {
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
with: {
destination: true,
},
});
return assignments;
};
const updateScheduleNotifications = async (
scheduleId: number,
assignments: Array<{
destinationId: number;
notifyOnStart: boolean;
notifyOnSuccess: boolean;
notifyOnFailure: boolean;
}>,
) => {
await db.delete(backupScheduleNotificationsTable).where(eq(backupScheduleNotificationsTable.scheduleId, scheduleId));
if (assignments.length > 0) {
await db.insert(backupScheduleNotificationsTable).values(
assignments.map((assignment) => ({
scheduleId,
...assignment,
})),
);
}
return getScheduleNotifications(scheduleId);
};
const sendBackupNotification = async (
scheduleId: number,
event: NotificationEvent,
context: {
volumeName: string;
repositoryName: string;
scheduleName?: string;
error?: string;
duration?: number;
filesProcessed?: number;
bytesProcessed?: string;
snapshotId?: string;
},
) => {
try {
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
with: {
destination: true,
},
});
const relevantAssignments = assignments.filter((assignment) => {
if (!assignment.destination.enabled) return false;
switch (event) {
case "start":
return assignment.notifyOnStart;
case "success":
return assignment.notifyOnSuccess;
case "failure":
case "warning":
return assignment.notifyOnFailure;
default:
return false;
}
});
if (!relevantAssignments.length) {
logger.debug(`No notification destinations configured for backup ${scheduleId} event ${event}`);
return;
}
const { title, body } = buildNotificationMessage(event, context);
for (const assignment of relevantAssignments) {
try {
const decryptedConfig = await decryptSensitiveFields(assignment.destination.config);
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
const result = await sendNotification({
shoutrrrUrl,
title,
body,
});
if (result.success) {
logger.info(
`Notification sent successfully to ${assignment.destination.name} for backup ${scheduleId} event ${event}`,
);
} else {
logger.error(
`Failed to send notification to ${assignment.destination.name} for backup ${scheduleId}: ${result.error}`,
);
}
} catch (error) {
logger.error(
`Error sending notification to ${assignment.destination.name} for backup ${scheduleId}: ${toMessage(error)}`,
);
}
}
} catch (error) {
logger.error(`Error processing backup notifications for schedule ${scheduleId}: ${toMessage(error)}`);
}
};
function buildNotificationMessage(
event: NotificationEvent,
context: {
volumeName: string;
repositoryName: string;
scheduleName?: string;
error?: string;
duration?: number;
filesProcessed?: number;
bytesProcessed?: string;
snapshotId?: string;
},
) {
const date = new Date().toLocaleDateString();
const time = new Date().toLocaleTimeString();
switch (event) {
case "start":
return {
title: "🔵 Backup Started",
body: [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
`Time: ${date} - ${time}`,
]
.filter(Boolean)
.join("\n"),
};
case "success":
return {
title: "✅ Backup Completed successfully",
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,
`Time: ${date} - ${time}`,
]
.filter(Boolean)
.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":
return {
title: "❌ Backup failed",
body: [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,
context.error ? `Error: ${context.error}` : null,
`Time: ${date} - ${time}`,
]
.filter(Boolean)
.join("\n"),
};
default:
return {
title: "Backup Notification",
body: `Volume: ${context.volumeName}\nRepository: ${context.repositoryName}\nTime: ${date} - ${time}`,
};
}
}
export const notificationsService = {
listDestinations,
getDestination,
createDestination,
updateDestination,
deleteDestination,
testDestination,
getScheduleNotifications,
updateScheduleNotifications,
sendBackupNotification,
};

View File

@@ -16,6 +16,8 @@ import {
listSnapshotsFilters,
restoreSnapshotBody,
restoreSnapshotDto,
updateRepositoryBody,
updateRepositoryDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DoctorRepositoryDto,
@@ -25,6 +27,7 @@ import {
type ListSnapshotFilesDto,
type ListSnapshotsDto,
type RestoreSnapshotDto,
type UpdateRepositoryDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
@@ -123,7 +126,8 @@ export const repositoriesController = new Hono()
const { name, snapshotId } = c.req.param();
const { path } = c.req.valid("query");
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
const decodedPath = path ? decodeURIComponent(path) : undefined;
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, decodedPath);
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
@@ -151,4 +155,12 @@ export const repositoriesController = new Hono()
await repositoriesService.deleteSnapshot(name, snapshotId);
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({
id: "string",
shortId: "string",
name: "string",
type: type.valueOf(REPOSITORY_BACKENDS),
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
*/
@@ -237,6 +273,7 @@ export const restoreSnapshotBody = type({
snapshotId: "string",
include: "string[]?",
exclude: "string[]?",
excludeXattr: "string[]?",
delete: "boolean?",
});

View File

@@ -1,10 +1,11 @@
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 slugify from "slugify";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
@@ -15,7 +16,7 @@ const listRepositories = async () => {
};
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
const encryptedConfig: Record<string, string | boolean> = { ...config };
const encryptedConfig: Record<string, string | boolean | number> = { ...config };
if (config.customPassword) {
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
@@ -61,13 +62,20 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
}
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
.insert(repositoriesTable)
.values({
id,
shortId,
name: slug,
type: config.backend,
config: encryptedConfig,
@@ -193,7 +201,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
const restoreSnapshot = async (
name: string,
snapshotId: string,
options?: { include?: string[]; exclude?: string[]; delete?: boolean },
options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean },
) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
@@ -350,11 +358,53 @@ const deleteSnapshot = async (name: string, snapshotId: string) => {
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");
}
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 = {
listRepositories,
createRepository,
getRepository,
deleteRepository,
updateRepository,
listSnapshots,
listSnapshotFiles,
restoreSnapshot,

View File

@@ -6,5 +6,5 @@ export const getVolumePath = (volume: Volume) => {
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({
id: "number",
shortId: "string",
name: "string",
type: type.valueOf(BACKEND_TYPES),
status: type.valueOf(BACKEND_STATUS),
@@ -128,6 +129,7 @@ export const getVolumeDto = describeRoute({
* Update a volume
*/
export const updateVolumeBody = type({
name: "string?",
autoRemount: "boolean?",
config: volumeConfigSchema.optional(),
});

View File

@@ -2,13 +2,14 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
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 slugify from "slugify";
import { getCapabilities } from "../../core/capabilities";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { withTimeout } from "../../utils/timeout";
import { createVolumeBackend } from "../backends/backend";
@@ -35,9 +36,12 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
throw new ConflictError("Volume already exists");
}
const shortId = generateShortId();
const [created] = await db
.insert(volumesTable)
.values({
shortId,
name: slug,
config: backendConfig,
type: backendConfig.backend,
@@ -147,6 +151,21 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
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 =
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
.update(volumesTable)
.set({
name: newName,
config: volumeData.config,
type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount,
updatedAt: Date.now(),
})
.where(eq(volumesTable.name, name))
.where(eq(volumesTable.id, existing.id))
.returning();
if (!updated) {
@@ -177,9 +197,9 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
await db
.update(volumesTable)
.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 };
@@ -190,6 +210,7 @@ const testConnection = async (backendConfig: BackendConfig) => {
const mockVolume = {
id: 0,
shortId: "test",
name: "test-connection",
path: tempDir,
config: backendConfig,
@@ -264,7 +285,7 @@ const getContainersUsingVolume = async (name: string) => {
const container = docker.getContainer(info.Id);
const inspect = await container.inspect();
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) {
usingContainers.push({
id: inspect.Id,

View File

@@ -17,3 +17,25 @@ export const toMessage = (err: unknown): string => {
const message = err instanceof Error ? err.message : String(err);
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 type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn";
import type { RepositoryConfig } from "~/schemas/restic";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import { ResticError } from "./errors";
const backupOutputSchema = type({
message_type: "'summary'",
@@ -234,6 +235,7 @@ const backup = async (
exclude?: string[];
include?: string[];
tags?: string[];
compressionMode?: CompressionMode;
signal?: AbortSignal;
onProgress?: (progress: BackupProgress) => void;
},
@@ -241,7 +243,14 @@ const backup = async (
const repoUrl = buildRepoUrl(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) {
for (const tag of options.tags) {
@@ -291,6 +300,7 @@ const backup = async (
let stdout = "";
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({
command: "restic",
args,
@@ -304,34 +314,41 @@ const backup = async (
streamProgress(data);
}
},
onStderr: (error) => {
logger.error(error.trim());
},
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
await cleanupTemporaryKeys(config, env);
},
});
if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`);
if (res.exitCode === 3) {
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(" ")}`);
throw new Error(`Restic backup failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
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) {
logger.error(`Restic backup output validation failed: ${result}`);
throw new Error(`Restic backup output validation failed: ${result}`);
return { result: null, exitCode: res.exitCode };
}
return result;
return { result, exitCode: res.exitCode };
};
const restoreOutputSchema = type({
@@ -351,6 +368,7 @@ const restore = async (
options?: {
include?: string[];
exclude?: string[];
excludeXattr?: string[];
path?: string;
delete?: boolean;
},
@@ -380,17 +398,21 @@ const restore = async (
}
}
if (options?.excludeXattr && options.excludeXattr.length > 0) {
for (const xattr of options.excludeXattr) {
args.push("--exclude-xattr", xattr);
}
}
addRepoSpecificArgs(args, config, env);
args.push("--json");
console.log("Restic restore command:", ["restic", ...args].join(" "));
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
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();
@@ -408,6 +430,7 @@ const restore = async (
};
}
logger.debug(`Restic restore output last line: ${lastLine}`);
const resSummary = JSON.parse(lastLine);
const result = restoreOutputSchema(resSummary);
@@ -502,7 +525,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
if (res.exitCode !== 0) {
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 };
@@ -520,7 +543,7 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
if (res.exitCode !== 0) {
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 };
@@ -565,16 +588,16 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
addRepoSpecificArgs(args, config, env);
const res = await $`restic ${args}`.env(env).nothrow().quiet();
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
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
const stdout = res.text();
const stdout = res.stdout;
const lines = stdout
.trim()
.split("\n")
@@ -621,7 +644,7 @@ const unlock = async (config: RepositoryConfig) => {
if (res.exitCode !== 0) {
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}`);
@@ -682,7 +705,7 @@ const repairIndex = async (config: RepositoryConfig) => {
if (res.exitCode !== 0) {
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}`);

View File

@@ -0,0 +1,43 @@
import { safeSpawn } from "./spawn";
import { logger } from "./logger";
import { toMessage } from "./errors";
export interface SendNotificationParams {
shoutrrrUrl: string;
title: string;
body: string;
}
export async function sendNotification(params: SendNotificationParams) {
const { shoutrrrUrl, title, body } = params;
try {
const args = ["send", "--url", shoutrrrUrl, "--title", title, "--message", body];
logger.debug(`Sending notification via Shoutrrr: ${title}`);
const result = await safeSpawn({
command: "shoutrrr",
args,
});
if (result.exitCode === 0) {
logger.debug(`Notification sent successfully: ${title}`);
return { success: true };
}
const errorMessage = result.stderr || result.stdout || "Unknown error";
logger.error(`Failed to send notification: ${errorMessage}`);
return {
success: false,
error: errorMessage,
};
} catch (error) {
const errorMessage = toMessage(error);
logger.error(`Error sending notification: ${errorMessage}`);
return {
success: false,
error: errorMessage,
};
}
}

View File

@@ -41,9 +41,8 @@ export const safeSpawn = (params: Params) => {
child.stderr.on("data", (data) => {
if (callbacks.onStderr) {
callbacks.onStderr(data.toString());
} else {
stderrData += data.toString();
}
stderrData += data.toString();
});
child.on("error", async (error) => {

View File

@@ -2,7 +2,6 @@
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "@ironmount/client",
"dependencies": {
"@hono/standard-validator": "^0.1.5",
"@hookform/resolvers": "^5.2.2",

View File

@@ -20,9 +20,8 @@ services:
- ./app:/app/app
- ~/.config/rclone:/root/.config/rclone
- /var/lib/zerobyte:/var/lib/zerobyte:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock
# - /run/docker/plugins:/run/docker/plugins
# - /var/run/docker.sock:/var/run/docker.sock
zerobyte-prod:
build:

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB