Compare commits

...

11 Commits

Author SHA1 Message Date
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
47 changed files with 4401 additions and 209 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
@@ -36,7 +36,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:
@@ -51,7 +51,7 @@ services:
```
> [!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 +72,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:
@@ -138,7 +138,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:
@@ -195,7 +195,7 @@ 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:
@@ -224,7 +224,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:

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, updateScheduleNotifications, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
/**
* Register a new user
@@ -703,6 +703,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, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -438,6 +438,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

@@ -756,6 +756,15 @@ 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;
@@ -763,7 +772,7 @@ export type ListRepositoriesResponses = {
lastError: string | null;
name: 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 +832,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,6 +970,15 @@ 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;
@@ -959,7 +986,7 @@ export type GetRepositoryResponses = {
lastError: string | null;
name: 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;
};
};
@@ -1084,6 +1111,7 @@ export type RestoreSnapshotData = {
snapshotId: string;
delete?: boolean;
exclude?: Array<string>;
excludeXattr?: Array<string>;
include?: Array<string>;
};
path: {
@@ -1208,6 +1236,15 @@ 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;
@@ -1215,7 +1252,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null;
name: 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;
@@ -1430,6 +1467,15 @@ 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;
@@ -1437,7 +1483,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null;
name: 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;
@@ -1633,6 +1679,15 @@ 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;
@@ -1640,7 +1695,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null;
name: 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;
@@ -1769,6 +1824,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,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

@@ -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,7 +231,34 @@ 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">
<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}
@@ -230,6 +268,10 @@ export const SnapshotFileBrowser = (props: Props) => {
Delete files not present in the snapshot?
</Label>
</div>
</div>
)}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmRestore}>Confirm</AlertDialogAction>

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,7 +135,12 @@ 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} />
</div>

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

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

@@ -78,6 +78,13 @@
"when": 1762610065889,
"tag": "0010_perfect_proemial_gods",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1763644043601,
"tag": "0011_familiar_stone_men",
"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,88 @@
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",
} as const;
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;

View File

@@ -1,7 +1,8 @@
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
@@ -90,7 +91,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
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 +100,54 @@ 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;

View File

@@ -15,6 +15,7 @@ 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";
@@ -46,6 +47,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));

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

@@ -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
@@ -228,6 +238,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await restic.backup(repository.config, volumePath, {
...backupOptions,
compressionMode: repository.compressionMode ?? "auto",
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
@@ -262,6 +273,15 @@ const executeBackup = async (scheduleId: number, manual = false) => {
repositoryName: repository.name,
status: "success",
});
notificationsService
.sendBackupNotification(scheduleId, "success", {
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 +302,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

@@ -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,24 @@
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,415 @@
import { eq, and } 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), eq(notificationDestinationsTable.id, id)),
});
if (conflict && conflict.id !== id) {
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":
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 "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

@@ -123,7 +123,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");

View File

@@ -237,6 +237,7 @@ export const restoreSnapshotBody = type({
snapshotId: "string",
include: "string[]?",
exclude: "string[]?",
excludeXattr: "string[]?",
delete: "boolean?",
});

View File

@@ -15,7 +15,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);
@@ -193,7 +193,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),

View File

@@ -9,7 +9,7 @@ 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";
const backupOutputSchema = type({
message_type: "'summary'",
@@ -234,6 +234,7 @@ const backup = async (
exclude?: string[];
include?: string[];
tags?: string[];
compressionMode?: CompressionMode;
signal?: AbortSignal;
onProgress?: (progress: BackupProgress) => void;
},
@@ -241,7 +242,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 +299,7 @@ const backup = async (
let stdout = "";
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({
command: "restic",
args,
@@ -351,6 +360,7 @@ const restore = async (
options?: {
include?: string[];
exclude?: string[];
excludeXattr?: string[];
path?: string;
delete?: boolean;
},
@@ -380,11 +390,15 @@ 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);
@@ -408,6 +422,7 @@ const restore = async (
};
}
logger.debug(`Restic restore output last line: ${lastLine}`);
const resSummary = JSON.parse(lastLine);
const result = restoreOutputSchema(resSummary);
@@ -565,7 +580,7 @@ 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) {
@@ -574,7 +589,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
}
// 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")

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB