diff --git a/Dockerfile b/Dockerfile index 2a4c079..5267666 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 7cdc0d7..b747bfb 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -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>): Us return mutationOptions; }; +export const getScheduleNotificationsQueryKey = (options: Options) => createQueryKey("getScheduleNotifications", options); + +/** + * Get notification assignments for a backup schedule + */ +export const getScheduleNotificationsOptions = (options: Options) => queryOptions>({ + 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>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateScheduleNotifications({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const listNotificationDestinationsQueryKey = (options?: Options) => createQueryKey("listNotificationDestinations", options); + +/** + * List all notification destinations + */ +export const listNotificationDestinationsOptions = (options?: Options) => queryOptions>({ + 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>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await createNotificationDestination({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Delete a notification destination + */ +export const deleteNotificationDestinationMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteNotificationDestination({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const getNotificationDestinationQueryKey = (options: Options) => createQueryKey("getNotificationDestination", options); + +/** + * Get a notification destination by ID + */ +export const getNotificationDestinationOptions = (options: Options) => queryOptions>({ + 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>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + 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>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await testNotificationDestination({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getSystemInfoQueryKey = (options?: Options) => createQueryKey("getSystemInfo", options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index c71979b..df14961 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -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 = Options2 & { /** @@ -438,6 +438,98 @@ export const runForget = (options: Options }); }; +/** + * Get notification assignments for a backup schedule + */ +export const getScheduleNotifications = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/v1/backups/{scheduleId}/notifications', + ...options + }); +}; + +/** + * Update notification assignments for a backup schedule + */ +export const updateScheduleNotifications = (options: Options) => { + return (options.client ?? client).put({ + url: '/api/v1/backups/{scheduleId}/notifications', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +/** + * List all notification destinations + */ +export const listNotificationDestinations = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/api/v1/notifications/destinations', + ...options + }); +}; + +/** + * Create a new notification destination + */ +export const createNotificationDestination = (options?: Options) => { + return (options?.client ?? client).post({ + url: '/api/v1/notifications/destinations', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Delete a notification destination + */ +export const deleteNotificationDestination = (options: Options) => { + return (options.client ?? client).delete({ + url: '/api/v1/notifications/destinations/{id}', + ...options + }); +}; + +/** + * Get a notification destination by ID + */ +export const getNotificationDestination = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/v1/notifications/destinations/{id}', + ...options + }); +}; + +/** + * Update a notification destination + */ +export const updateNotificationDestination = (options: Options) => { + return (options.client ?? client).patch({ + 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 = (options: Options) => { + return (options.client ?? client).post({ + url: '/api/v1/notifications/destinations/{id}/test', + ...options + }); +}; + /** * Get system information including available capabilities */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index a36e74b..8bd9a8f 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1769,6 +1769,536 @@ 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: { + from: string; + password: string; + smtpHost: string; + smtpPort: number; + to: Array; + 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' | '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: { + from: string; + password: string; + smtpHost: string; + smtpPort: number; + to: Array; + 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' | '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: { + from: string; + password: string; + smtpHost: string; + smtpPort: number; + to: Array; + 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' | 'slack'; + updatedAt: number; + }>; +}; + +export type ListNotificationDestinationsResponse = ListNotificationDestinationsResponses[keyof ListNotificationDestinationsResponses]; + +export type CreateNotificationDestinationData = { + body?: { + config: { + from: string; + password: string; + smtpHost: string; + smtpPort: number; + to: Array; + 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: { + from: string; + password: string; + smtpHost: string; + smtpPort: number; + to: Array; + 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' | '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: { + from: string; + password: string; + smtpHost: string; + smtpPort: number; + to: Array; + 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' | 'slack'; + updatedAt: number; + }; +}; + +export type GetNotificationDestinationResponse = GetNotificationDestinationResponses[keyof GetNotificationDestinationResponses]; + +export type UpdateNotificationDestinationData = { + body?: { + config?: { + from: string; + password: string; + smtpHost: string; + smtpPort: number; + to: Array; + 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: { + from: string; + password: string; + smtpHost: string; + smtpPort: number; + to: Array; + 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' | '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; diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index 8710b68..813115d 100644 --- a/app/client/components/app-sidebar.tsx +++ b/app/client/components/app-sidebar.tsx @@ -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() { - Zerobyte Logo + Zerobyte Logo { +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 ( @@ -42,7 +54,7 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => { -

{status}

+

{label}

); diff --git a/app/client/lib/types.ts b/app/client/lib/types.ts index 872d638..4c99ae5 100644 --- a/app/client/lib/types.ts +++ b/app/client/lib/types.ts @@ -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]; diff --git a/app/client/modules/backups/components/backup-status-dot.tsx b/app/client/modules/backups/components/backup-status-dot.tsx index 624b07c..6d30224 100644 --- a/app/client/modules/backups/components/backup-status-dot.tsx +++ b/app/client/modules/backups/components/backup-status-dot.tsx @@ -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 ( - - - - {statusMapping.animated && ( - - )} - - - - -

{statusMapping.label}

-
-
- ); + return ; }; diff --git a/app/client/modules/backups/components/schedule-notifications-config.tsx b/app/client/modules/backups/components/schedule-notifications-config.tsx new file mode 100644 index 0000000..40358c9 --- /dev/null +++ b/app/client/modules/backups/components/schedule-notifications-config.tsx @@ -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>(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(); + 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(); + 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 ( + + +
+
+ + + Notifications + + Configure which notifications to send for this backup schedule +
+ {!isAddingNew && availableDestinations.length > 0 && ( + + )} +
+
+ + {isAddingNew && ( +
+ + +
+ )} + + {assignedDestinations.length === 0 ? ( +
+ +

No notifications configured for this schedule.

+

Click "Add notification" to get started.

+
+ ) : ( +
+ + + + Destination + Start + Success + Failure + + + + + {assignedDestinations.map((destination) => { + const assignment = assignments.get(destination.id); + if (!assignment) return null; + + return ( + + +
+ {destination.name} + + {destination.type} + +
+
+ + toggleEvent(destination.id, "notifyOnStart")} + /> + + + toggleEvent(destination.id, "notifyOnSuccess")} + /> + + + toggleEvent(destination.id, "notifyOnFailure")} + /> + + + + +
+ ); + })} +
+
+
+ )} + + {hasChanges && ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 0be7a2a..c7c8288 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -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} /> +
+ +
formSchema(deepClean(d))); + +export type NotificationFormValues = typeof formSchema.inferIn; + +type Props = { + onSubmit: (values: NotificationFormValues) => void; + mode?: "create" | "update"; + initialValues?: Partial; + 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, + }, + custom: { + type: "custom" as const, + shoutrrrUrl: "", + }, +}; + +export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => { + const form = useForm({ + 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 ( +
+ + ( + + Name + + field.onChange(slugify(e.target.value))} + max={32} + min={2} + disabled={mode === "update"} + className={mode === "update" ? "bg-gray-50" : ""} + /> + + Unique identifier for this notification destination. + + + )} + /> + + ( + + Type + + Choose the notification delivery method. + + + )} + /> + + {watchedType === "email" && ( + <> + ( + + SMTP Host + + + + + + )} + /> + ( + + SMTP Port + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + ( + + Username + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + From Address + + + + + + )} + /> + ( + + To Addresses + + field.onChange(e.target.value.split(",").map((email) => email.trim()))} + /> + + Comma-separated list of recipient email addresses. + + + )} + /> + ( + + + + +
+ Use TLS + Enable TLS encryption for SMTP connection. +
+
+ )} + /> + + )} + + {watchedType === "slack" && ( + <> + ( + + Webhook URL + + + + Get this from your Slack app's Incoming Webhooks settings. + + + )} + /> + ( + + Channel (Optional) + + + + Override the default channel (use # for channels, @ for users). + + + )} + /> + ( + + Bot Username (Optional) + + + + + + )} + /> + ( + + Icon Emoji (Optional) + + + + + + )} + /> + + )} + + {watchedType === "discord" && ( + <> + ( + + Webhook URL + + + + Get this from your Discord server's Integrations settings. + + + )} + /> + ( + + Bot Username (Optional) + + + + + + )} + /> + ( + + Avatar URL (Optional) + + + + + + )} + /> + + )} + + {watchedType === "gotify" && ( + <> + ( + + Server URL + + + + Your self-hosted Gotify server URL. + + + )} + /> + ( + + App Token + + + + Application token from Gotify. + + + )} + /> + ( + + Priority + + field.onChange(Number(e.target.value))} + /> + + Priority level (0-10, where 10 is highest). + + + )} + /> + + )} + + {watchedType === "ntfy" && ( + <> + ( + + Server URL (Optional) + + + + Leave empty to use ntfy.sh public service. + + + )} + /> + ( + + Topic + + + + The ntfy topic name to publish to. + + + )} + /> + ( + + Access Token (Optional) + + + + Required if the topic is protected. + + + )} + /> + ( + + Priority + + + + )} + /> + + )} + + {watchedType === "custom" && ( + ( + + Shoutrrr URL + + + + + Direct Shoutrrr URL for power users. See  + + Shoutrrr documentation + +   for supported services and URL formats. + + + + )} + /> + )} + + + ); +}; diff --git a/app/client/modules/notifications/routes/create-notification.tsx b/app/client/modules/notifications/routes/create-notification.tsx new file mode 100644 index 0000000..6f6f0dd --- /dev/null +++ b/app/client/modules/notifications/routes/create-notification.tsx @@ -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 ( +
+ + +
+
+ +
+ Create Notification Destination +
+
+ + {createNotification.isError && ( + + + Failed to create notification destination: +
+ {parseError(createNotification.error)?.message} +
+
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/app/client/modules/notifications/routes/notification-details.tsx b/app/client/modules/notifications/routes/notification-details.tsx new file mode 100644 index 0000000..b832dbb --- /dev/null +++ b/app/client/modules/notifications/routes/notification-details.tsx @@ -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 ( + <> +
+
+ + {data.enabled ? "Enabled" : "Disabled"} + + {data.type} +
+
+ + +
+
+ + + +
+
+ +
+ {data.name} +
+
+ + {updateDestination.isError && ( + + + Failed to update notification destination: +
+ {parseError(updateDestination.error)?.message} +
+
+ )} + <> + +
+ +
+ +
+
+ + + + + Delete Notification Destination + + 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. + + + + Cancel + Delete + + + + + ); +} diff --git a/app/client/modules/notifications/routes/notifications.tsx b/app/client/modules/notifications/routes/notifications.tsx new file mode 100644 index 0000000..546c888 --- /dev/null +++ b/app/client/modules/notifications/routes/notifications.tsx @@ -0,0 +1,176 @@ +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 ( + navigate("/notifications/create")}> + + Create Destination + + } + /> + ); + } + + return ( + +
+ + setSearchQuery(e.target.value)} + /> + + + {(searchQuery || typeFilter || statusFilter) && ( + + )} + + +
+
+ + + + Name + Type + Status + + + + {hasNoFilteredNotifications ? ( + + +
+

No destinations match your filters.

+ +
+
+
+ ) : ( + filteredNotifications.map((notification) => ( + navigate(`/notifications/${notification.id}`)} + > + {notification.name} + {notification.type} + + + + + )) + )} +
+
+
+
+ + {filteredNotifications.length} destination + {filteredNotifications.length !== 1 ? "s" : ""} + +
+
+ ); +} diff --git a/app/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx index ed80334..1cd1e07 100644 --- a/app/client/modules/volumes/routes/volume-details.tsx +++ b/app/client/modules/volumes/routes/volume-details.tsx @@ -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) {
- {volume.status[0].toUpperCase() + volume.status.slice(1)} + +   + {volume.status[0].toUpperCase() + volume.status.slice(1)}
diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index 2f90385..fc8bbda 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -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) { - + )) diff --git a/app/drizzle/0011_familiar_stone_men.sql b/app/drizzle/0011_familiar_stone_men.sql new file mode 100644 index 0000000..3b30437 --- /dev/null +++ b/app/drizzle/0011_familiar_stone_men.sql @@ -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`); \ No newline at end of file diff --git a/app/drizzle/meta/0011_snapshot.json b/app/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..4055b00 --- /dev/null +++ b/app/drizzle/meta/0011_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index abdba02..6310637 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/app/routes.ts b/app/routes.ts index 3beb7c3..a7a2eb3 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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; diff --git a/app/schemas/notifications.ts b/app/schemas/notifications.ts new file mode 100644 index 0000000..da20d15 --- /dev/null +++ b/app/schemas/notifications.ts @@ -0,0 +1,75 @@ +import { type } from "arktype"; + +export const NOTIFICATION_TYPES = { + email: "email", + slack: "slack", + discord: "discord", + gotify: "gotify", + ntfy: "ntfy", + 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 customNotificationConfigSchema = type({ + type: "'custom'", + shoutrrrUrl: "string", +}); + +export const notificationConfigSchema = emailNotificationConfigSchema + .or(slackNotificationConfigSchema) + .or(discordNotificationConfigSchema) + .or(gotifyNotificationConfigSchema) + .or(ntfyNotificationConfigSchema) + .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; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index dbb2fff..bdcb37a 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -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().notNull(), + config: text("config", { mode: "json" }).$type().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; diff --git a/app/server/index.ts b/app/server/index.ts index bcd911a..cf741e4 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -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)); diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index e941af0..ed6fa4a 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -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({ 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(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(assignments, 200); + }, + ); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 9e98459..2a6dde4 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -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(); @@ -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); diff --git a/app/server/modules/notifications/builders/custom.ts b/app/server/modules/notifications/builders/custom.ts new file mode 100644 index 0000000..8da3505 --- /dev/null +++ b/app/server/modules/notifications/builders/custom.ts @@ -0,0 +1,5 @@ +import type { NotificationConfig } from "~/schemas/notifications"; + +export function buildCustomShoutrrrUrl(config: Extract): string { + return config.shoutrrrUrl; +} diff --git a/app/server/modules/notifications/builders/discord.ts b/app/server/modules/notifications/builders/discord.ts new file mode 100644 index 0000000..96c1520 --- /dev/null +++ b/app/server/modules/notifications/builders/discord.ts @@ -0,0 +1,28 @@ +import type { NotificationConfig } from "~/schemas/notifications"; + +export function buildDiscordShoutrrrUrl(config: Extract): 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; +} diff --git a/app/server/modules/notifications/builders/email.ts b/app/server/modules/notifications/builders/email.ts new file mode 100644 index 0000000..5fd2ee3 --- /dev/null +++ b/app/server/modules/notifications/builders/email.ts @@ -0,0 +1,10 @@ +import type { NotificationConfig } from "~/schemas/notifications"; + +export function buildEmailShoutrrrUrl(config: Extract): 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}`; +} diff --git a/app/server/modules/notifications/builders/gotify.ts b/app/server/modules/notifications/builders/gotify.ts new file mode 100644 index 0000000..d871ed9 --- /dev/null +++ b/app/server/modules/notifications/builders/gotify.ts @@ -0,0 +1,15 @@ +import type { NotificationConfig } from "~/schemas/notifications"; + +export function buildGotifyShoutrrrUrl(config: Extract): 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; +} diff --git a/app/server/modules/notifications/builders/index.ts b/app/server/modules/notifications/builders/index.ts new file mode 100644 index 0000000..ffe84c3 --- /dev/null +++ b/app/server/modules/notifications/builders/index.ts @@ -0,0 +1,29 @@ +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 { 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 "custom": + return buildCustomShoutrrrUrl(config); + default: { + // TypeScript exhaustiveness check + const _exhaustive: never = config; + throw new Error(`Unsupported notification type: ${(_exhaustive as NotificationConfig).type}`); + } + } +} diff --git a/app/server/modules/notifications/builders/ntfy.ts b/app/server/modules/notifications/builders/ntfy.ts new file mode 100644 index 0000000..b233e2a --- /dev/null +++ b/app/server/modules/notifications/builders/ntfy.ts @@ -0,0 +1,28 @@ +import type { NotificationConfig } from "~/schemas/notifications"; + +export function buildNtfyShoutrrrUrl(config: Extract): 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; +} diff --git a/app/server/modules/notifications/builders/slack.ts b/app/server/modules/notifications/builders/slack.ts new file mode 100644 index 0000000..37f1c61 --- /dev/null +++ b/app/server/modules/notifications/builders/slack.ts @@ -0,0 +1,31 @@ +import type { NotificationConfig } from "~/schemas/notifications"; + +export function buildSlackShoutrrrUrl(config: Extract): 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; +} diff --git a/app/server/modules/notifications/notifications.controller.ts b/app/server/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..643d03b --- /dev/null +++ b/app/server/modules/notifications/notifications.controller.ts @@ -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(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(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(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(destination, 200); + }) + .delete("/destinations/:id", deleteDestinationDto, async (c) => { + const id = Number.parseInt(c.req.param("id"), 10); + await notificationsService.deleteDestination(id); + return c.json({ 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(result, 200); + }); diff --git a/app/server/modules/notifications/notifications.dto.ts b/app/server/modules/notifications/notifications.dto.ts new file mode 100644 index 0000000..65d682f --- /dev/null +++ b/app/server/modules/notifications/notifications.dto.ts @@ -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), + }, + }, + }, + }, +}); diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..16d6806 --- /dev/null +++ b/app/server/modules/notifications/notifications.service.ts @@ -0,0 +1,405 @@ +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 { + 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 "custom": + return { + ...config, + shoutrrrUrl: await cryptoUtils.encrypt(config.shoutrrrUrl), + }; + default: + return config; + } +} + +async function decryptSensitiveFields(config: NotificationConfig): Promise { + 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 "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 = { + 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, +}; diff --git a/app/server/utils/shoutrrr.ts b/app/server/utils/shoutrrr.ts new file mode 100644 index 0000000..18475c8 --- /dev/null +++ b/app/server/utils/shoutrrr.ts @@ -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, + }; + } +} diff --git a/bun.lock b/bun.lock index 2bddbf8..489487d 100644 --- a/bun.lock +++ b/bun.lock @@ -2,7 +2,6 @@ "lockfileVersion": 1, "workspaces": { "": { - "name": "@ironmount/client", "dependencies": { "@hono/standard-validator": "^0.1.5", "@hookform/resolvers": "^5.2.2",