mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
9 Commits
v0.11.1-be
...
v0.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2418870284 | ||
|
|
43dfe6b190 | ||
|
|
8c4939af4e | ||
|
|
a622b5e689 | ||
|
|
6c30e7e357 | ||
|
|
043f73ea87 | ||
|
|
518700eef6 | ||
|
|
a250c442f8 | ||
|
|
6981600ad7 |
14
Dockerfile
14
Dockerfile
@@ -2,7 +2,7 @@ ARG BUN_VERSION="1.3.1"
|
||||
|
||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||
|
||||
RUN apk add --no-cache davfs2=1.6.1-r2
|
||||
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client
|
||||
|
||||
|
||||
# ------------------------------
|
||||
@@ -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
|
||||
|
||||
12
README.md
12
README.md
@@ -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.10
|
||||
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.10
|
||||
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.10
|
||||
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.10
|
||||
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.10
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.13
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,584 @@ 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';
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
} | {
|
||||
priority: number;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
type: 'gotify';
|
||||
} | {
|
||||
shoutrrrUrl: string;
|
||||
type: 'custom';
|
||||
} | {
|
||||
type: 'discord';
|
||||
webhookUrl: string;
|
||||
avatarUrl?: 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';
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
} | {
|
||||
priority: number;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
type: 'gotify';
|
||||
} | {
|
||||
shoutrrrUrl: string;
|
||||
type: 'custom';
|
||||
} | {
|
||||
type: 'discord';
|
||||
webhookUrl: string;
|
||||
avatarUrl?: 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';
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
} | {
|
||||
priority: number;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
type: 'gotify';
|
||||
} | {
|
||||
shoutrrrUrl: string;
|
||||
type: 'custom';
|
||||
} | {
|
||||
type: 'discord';
|
||||
webhookUrl: string;
|
||||
avatarUrl?: 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';
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
} | {
|
||||
priority: number;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
type: 'gotify';
|
||||
} | {
|
||||
shoutrrrUrl: string;
|
||||
type: 'custom';
|
||||
} | {
|
||||
type: 'discord';
|
||||
webhookUrl: string;
|
||||
avatarUrl?: 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';
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
} | {
|
||||
priority: number;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
type: 'gotify';
|
||||
} | {
|
||||
shoutrrrUrl: string;
|
||||
type: 'custom';
|
||||
} | {
|
||||
type: 'discord';
|
||||
webhookUrl: string;
|
||||
avatarUrl?: 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';
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
} | {
|
||||
priority: number;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
type: 'gotify';
|
||||
} | {
|
||||
shoutrrrUrl: string;
|
||||
type: 'custom';
|
||||
} | {
|
||||
type: 'discord';
|
||||
webhookUrl: string;
|
||||
avatarUrl?: 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';
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
} | {
|
||||
priority: number;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
type: 'gotify';
|
||||
} | {
|
||||
shoutrrrUrl: string;
|
||||
type: 'custom';
|
||||
} | {
|
||||
type: 'discord';
|
||||
webhookUrl: string;
|
||||
avatarUrl?: 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';
|
||||
serverUrl?: string;
|
||||
token?: string;
|
||||
} | {
|
||||
priority: number;
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
type: 'gotify';
|
||||
} | {
|
||||
shoutrrrUrl: string;
|
||||
type: 'custom';
|
||||
} | {
|
||||
type: 'discord';
|
||||
webhookUrl: string;
|
||||
avatarUrl?: 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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./ui/alert-dialog";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
export const formSchema = type({
|
||||
name: "2<=string<=32",
|
||||
@@ -53,6 +54,7 @@ const defaultValuesForType = {
|
||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
|
||||
};
|
||||
|
||||
export const CreateRepositoryForm = ({
|
||||
@@ -141,6 +143,7 @@ export const CreateRepositoryForm = ({
|
||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||
<SelectItem value="rest">REST Server</SelectItem>
|
||||
<SelectItem value="sftp">SFTP</SelectItem>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||
@@ -268,18 +271,11 @@ export const CreateRepositoryForm = ({
|
||||
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowPathWarning(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
The directory where the repository will be stored.
|
||||
</FormDescription>
|
||||
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||
@@ -290,13 +286,9 @@ export const CreateRepositoryForm = ({
|
||||
Important: Host Mount Required
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-3">
|
||||
<p>
|
||||
When selecting a custom path, ensure it is mounted from the host machine into the
|
||||
container.
|
||||
</p>
|
||||
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
|
||||
<p className="font-medium">
|
||||
If the path is not a host mount, you will lose your repository data when the container
|
||||
restarts.
|
||||
If the path is not a host mount, you will lose your repository data when the container restarts.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
||||
@@ -703,6 +695,89 @@ export const CreateRepositoryForm = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "sftp" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>SFTP server hostname or IP address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>SSH port (default: 22).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backup-user" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>SSH username for authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="backups/ironmount" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Repository path on the SFTP server. </FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="privateKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SSH Private Key</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "update" && (
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Save Changes
|
||||
|
||||
@@ -546,7 +546,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend !== "directory" && (
|
||||
{watchedBackend && watchedBackend !== "directory" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -15,6 +15,7 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
|
||||
case "gcs":
|
||||
return <Cloud className={className} />;
|
||||
case "rest":
|
||||
case "sftp":
|
||||
return <Server className={className} />;
|
||||
default:
|
||||
return <Database className={className} />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { ChevronDown, FileIcon } from "lucide-react";
|
||||
import { FileTree } from "~/client/components/file-tree";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { Label } from "~/client/components/ui/label";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -39,6 +40,8 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
|
||||
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [excludeXattr, setExcludeXattr] = useState("");
|
||||
|
||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
||||
|
||||
@@ -64,9 +67,11 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
|
||||
const addBasePath = useCallback(
|
||||
(displayPath: string): string => {
|
||||
if (!volumeBasePath) return displayPath;
|
||||
if (displayPath === "/") return volumeBasePath;
|
||||
return `${volumeBasePath}${displayPath}`;
|
||||
let vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
||||
|
||||
if (!vbp) return displayPath;
|
||||
if (displayPath === "/") return vbp;
|
||||
return `${vbp}${displayPath}`;
|
||||
},
|
||||
[volumeBasePath],
|
||||
);
|
||||
@@ -117,17 +122,23 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
const pathsArray = Array.from(selectedPaths);
|
||||
const includePaths = pathsArray.map((path) => addBasePath(path));
|
||||
|
||||
const excludeXattrArray = excludeXattr
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
restoreSnapshot({
|
||||
path: { name: repositoryName },
|
||||
body: {
|
||||
snapshotId: snapshot.short_id,
|
||||
include: includePaths,
|
||||
delete: deleteExtraFiles,
|
||||
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
setShowRestoreDialog(false);
|
||||
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles]);
|
||||
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -220,15 +231,46 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex items-center space-x-2 py-4">
|
||||
<Checkbox
|
||||
id="delete-extra"
|
||||
checked={deleteExtraFiles}
|
||||
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
|
||||
Delete files not present in the snapshot?
|
||||
</Label>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="h-auto p-0 text-sm font-normal"
|
||||
>
|
||||
Advanced
|
||||
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="exclude-xattr" className="text-sm">
|
||||
Exclude Extended Attributes (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="exclude-xattr"
|
||||
placeholder="com.apple.metadata,user.*,nfs4.*"
|
||||
value={excludeXattr}
|
||||
onChange={(e) => setExcludeXattr(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Exclude specific extended attributes during restore (comma-separated)
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Checkbox
|
||||
id="delete-extra"
|
||||
checked={deleteExtraFiles}
|
||||
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
|
||||
Delete files not present in the snapshot?
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
@@ -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 ?? []}
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Token (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="••••••••" />
|
||||
</FormControl>
|
||||
<FormDescription>Required if the topic is protected.</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
|
||||
<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>
|
||||
for supported services and URL formats.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
208
app/client/modules/notifications/routes/notification-details.tsx
Normal file
208
app/client/modules/notifications/routes/notification-details.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
177
app/client/modules/notifications/routes/notifications.tsx
Normal file
177
app/client/modules/notifications/routes/notifications.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -181,8 +181,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||
and will remove all backup data.
|
||||
Are you sure you want to delete the repository <strong>{data.name}</strong>? This will not remove the
|
||||
actual data from the backend storage, only the repository configuration will be deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex gap-3 justify-end">
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
{volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
</span>
|
||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
23
app/drizzle/0011_familiar_stone_men.sql
Normal file
23
app/drizzle/0011_familiar_stone_men.sql
Normal 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`);
|
||||
620
app/drizzle/meta/0011_snapshot.json
Normal file
620
app/drizzle/meta/0011_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,90 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1755765658194,
|
||||
"tag": "0000_known_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1755775437391,
|
||||
"tag": "0001_far_frank_castle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1756930554198,
|
||||
"tag": "0002_cheerful_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1758653407064,
|
||||
"tag": "0003_mature_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1758961535488,
|
||||
"tag": "0004_wealthy_tomas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1759416698274,
|
||||
"tag": "0005_simple_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760734377440,
|
||||
"tag": "0006_secret_micromacro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1762095226041,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1763644043601,
|
||||
"tag": "0011_familiar_stone_men",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
85
app/schemas/notifications.ts
Normal file
85
app/schemas/notifications.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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?",
|
||||
});
|
||||
|
||||
export const gotifyNotificationConfigSchema = type({
|
||||
type: "'gotify'",
|
||||
serverUrl: "string",
|
||||
token: "string",
|
||||
priority: "0 <= number <= 10",
|
||||
});
|
||||
|
||||
export const ntfyNotificationConfigSchema = type({
|
||||
type: "'ntfy'",
|
||||
serverUrl: "string?",
|
||||
topic: "string",
|
||||
token: "string?",
|
||||
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -8,6 +8,7 @@ export const REPOSITORY_BACKENDS = {
|
||||
azure: "azure",
|
||||
rclone: "rclone",
|
||||
rest: "rest",
|
||||
sftp: "sftp",
|
||||
} as const;
|
||||
|
||||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||
@@ -69,13 +70,23 @@ export const restRepositoryConfigSchema = type({
|
||||
path: "string?",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const sftpRepositoryConfigSchema = type({
|
||||
backend: "'sftp'",
|
||||
host: "string",
|
||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||
user: "string",
|
||||
path: "string",
|
||||
privateKey: "string",
|
||||
}).and(baseRepositoryConfigSchema);
|
||||
|
||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||
.or(r2RepositoryConfigSchema)
|
||||
.or(localRepositoryConfigSchema)
|
||||
.or(gcsRepositoryConfigSchema)
|
||||
.or(azureRepositoryConfigSchema)
|
||||
.or(rcloneRepositoryConfigSchema)
|
||||
.or(restRepositoryConfigSchema);
|
||||
.or(restRepositoryConfigSchema)
|
||||
.or(sftpRepositoryConfigSchema);
|
||||
|
||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -262,6 +272,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 +301,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);
|
||||
|
||||
5
app/server/modules/notifications/builders/custom.ts
Normal file
5
app/server/modules/notifications/builders/custom.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildCustomShoutrrrUrl(config: Extract<NotificationConfig, { type: "custom" }>): string {
|
||||
return config.shoutrrrUrl;
|
||||
}
|
||||
28
app/server/modules/notifications/builders/discord.ts
Normal file
28
app/server/modules/notifications/builders/discord.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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("avatar_url", config.avatarUrl);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
shoutrrrUrl += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
10
app/server/modules/notifications/builders/email.ts
Normal file
10
app/server/modules/notifications/builders/email.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string {
|
||||
const protocol = config.useTLS ? "smtps" : "smtp";
|
||||
const auth = `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}`;
|
||||
const host = `${config.smtpHost}:${config.smtpPort}`;
|
||||
const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(",");
|
||||
|
||||
return `${protocol}://${auth}@${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}`;
|
||||
}
|
||||
15
app/server/modules/notifications/builders/gotify.ts
Normal file
15
app/server/modules/notifications/builders/gotify.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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}` : "";
|
||||
|
||||
let shoutrrrUrl = `gotify://${hostname}${port}/${config.token}`;
|
||||
|
||||
if (config.priority !== undefined) {
|
||||
shoutrrrUrl += `?priority=${config.priority}`;
|
||||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
32
app/server/modules/notifications/builders/index.ts
Normal file
32
app/server/modules/notifications/builders/index.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/server/modules/notifications/builders/ntfy.ts
Normal file
28
app/server/modules/notifications/builders/ntfy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
|
||||
export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string {
|
||||
let shoutrrrUrl: string;
|
||||
|
||||
if (config.serverUrl) {
|
||||
const url = new URL(config.serverUrl);
|
||||
const hostname = url.hostname;
|
||||
const port = url.port ? `:${url.port}` : "";
|
||||
shoutrrrUrl = `ntfy://${hostname}${port}/${config.topic}`;
|
||||
} else {
|
||||
shoutrrrUrl = `ntfy://ntfy.sh/${config.topic}`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (config.token) {
|
||||
params.append("token", config.token);
|
||||
}
|
||||
if (config.priority) {
|
||||
params.append("priority", config.priority);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
shoutrrrUrl += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
24
app/server/modules/notifications/builders/pushover.ts
Normal file
24
app/server/modules/notifications/builders/pushover.ts
Normal 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;
|
||||
}
|
||||
31
app/server/modules/notifications/builders/slack.ts
Normal file
31
app/server/modules/notifications/builders/slack.ts
Normal 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", config.iconEmoji);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
shoutrrrUrl += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return shoutrrrUrl;
|
||||
}
|
||||
51
app/server/modules/notifications/notifications.controller.ts
Normal file
51
app/server/modules/notifications/notifications.controller.ts
Normal 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);
|
||||
});
|
||||
251
app/server/modules/notifications/notifications.dto.ts
Normal file
251
app/server/modules/notifications/notifications.dto.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
415
app/server/modules/notifications/notifications.service.ts
Normal file
415
app/server/modules/notifications/notifications.service.ts
Normal 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,
|
||||
token: config.token ? await cryptoUtils.encrypt(config.token) : 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,
|
||||
token: config.token ? await cryptoUtils.decrypt(config.token) : 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,
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -237,6 +237,7 @@ export const restoreSnapshotBody = type({
|
||||
snapshotId: "string",
|
||||
include: "string[]?",
|
||||
exclude: "string[]?",
|
||||
excludeXattr: "string[]?",
|
||||
delete: "boolean?",
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -41,6 +41,9 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
||||
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
||||
}
|
||||
break;
|
||||
case "sftp":
|
||||
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey);
|
||||
break;
|
||||
}
|
||||
|
||||
return encryptedConfig as RepositoryConfig;
|
||||
@@ -190,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),
|
||||
|
||||
@@ -88,6 +88,8 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||
const path = config.path ? `/${config.path}` : "";
|
||||
return `rest:${config.url}${path}`;
|
||||
}
|
||||
case "sftp":
|
||||
return `sftp:${config.user}@${config.host}:${config.path}`;
|
||||
default: {
|
||||
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
||||
}
|
||||
@@ -146,6 +148,43 @@ const buildEnv = async (config: RepositoryConfig) => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sftp": {
|
||||
const decryptedKey = await cryptoUtils.decrypt(config.privateKey);
|
||||
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
|
||||
|
||||
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
||||
if (!normalizedKey.endsWith("\n")) {
|
||||
normalizedKey += "\n";
|
||||
}
|
||||
|
||||
if (normalizedKey.includes("ENCRYPTED")) {
|
||||
logger.error("SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.");
|
||||
throw new Error("Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.");
|
||||
}
|
||||
|
||||
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
env._SFTP_KEY_PATH = keyPath;
|
||||
|
||||
const sshArgs = [
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"LogLevel=VERBOSE",
|
||||
"-i",
|
||||
keyPath,
|
||||
];
|
||||
|
||||
if (config.port && config.port !== 22) {
|
||||
sshArgs.push("-p", String(config.port));
|
||||
}
|
||||
|
||||
env._SFTP_SSH_ARGS = sshArgs.join(" ");
|
||||
logger.info(`SFTP: SSH args: ${env._SFTP_SSH_ARGS}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
@@ -160,7 +199,11 @@ const init = async (config: RepositoryConfig) => {
|
||||
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
|
||||
const args = ["init", "--repo", repoUrl, "--json"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic init failed: ${res.stderr}`);
|
||||
@@ -225,6 +268,7 @@ const backup = async (
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const logData = throttle((data: string) => {
|
||||
@@ -265,6 +309,7 @@ const backup = async (
|
||||
},
|
||||
finally: async () => {
|
||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -306,6 +351,7 @@ const restore = async (
|
||||
options?: {
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
excludeXattr?: string[];
|
||||
path?: string;
|
||||
delete?: boolean;
|
||||
},
|
||||
@@ -335,11 +381,17 @@ const restore = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.excludeXattr && options.excludeXattr.length > 0) {
|
||||
for (const xattr of options.excludeXattr) {
|
||||
args.push("--exclude-xattr", xattr);
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
console.log("Restic restore command:", ["restic", ...args].join(" "));
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||
@@ -361,6 +413,7 @@ const restore = async (
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(`Restic restore output last line: ${lastLine}`);
|
||||
const resSummary = JSON.parse(lastLine);
|
||||
const result = restoreOutputSchema(resSummary);
|
||||
|
||||
@@ -397,9 +450,11 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||
@@ -445,9 +500,11 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
||||
}
|
||||
|
||||
args.push("--prune");
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
args.push("--json");
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||
@@ -462,8 +519,10 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||
@@ -510,7 +569,10 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow().quiet();
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic ls failed: ${res.stderr}`);
|
||||
@@ -518,7 +580,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")
|
||||
@@ -557,7 +619,11 @@ const unlock = async (config: RepositoryConfig) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
||||
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||
@@ -578,7 +644,10 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
||||
args.push("--read-data");
|
||||
}
|
||||
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
const stdout = res.text();
|
||||
const stderr = res.stderr.toString();
|
||||
@@ -608,7 +677,11 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const res = await $`restic repair index --repo ${repoUrl}`.env(env).nothrow();
|
||||
const args = ["repair", "index", "--repo", repoUrl];
|
||||
addRepoSpecificArgs(args, config, env);
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
await cleanupTemporaryKeys(config, env);
|
||||
|
||||
const stdout = res.text();
|
||||
const stderr = res.stderr.toString();
|
||||
@@ -626,6 +699,22 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||
};
|
||||
};
|
||||
|
||||
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
|
||||
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
|
||||
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
|
||||
if (config.backend === "sftp" && env._SFTP_KEY_PATH) {
|
||||
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
|
||||
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
|
||||
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
|
||||
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
export const restic = {
|
||||
ensurePassfile,
|
||||
init,
|
||||
|
||||
43
app/server/utils/shoutrrr.ts
Normal file
43
app/server/utils/shoutrrr.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user