diff --git a/Dockerfile b/Dockerfile index c3448f9a..806415d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,8 @@ RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr # ------------------------------ FROM base AS development +ARG APP_VERSION=dev +ENV APP_VERSION=${APP_VERSION} ENV NODE_ENV="development" WORKDIR /app @@ -84,6 +86,8 @@ RUN bun run build FROM base AS production +ARG APP_VERSION=dev +ENV APP_VERSION=${APP_VERSION} ENV NODE_ENV="production" WORKDIR /app diff --git a/README.md b/README.md index 5239aa06..ddb18ea7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o ```yaml services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.19 + image: ghcr.io/nicotsx/zerobyte:v0.21 container_name: zerobyte restart: unless-stopped cap_add: @@ -77,7 +77,7 @@ If you only need to back up locally mounted folders and don't require remote sha ```yaml services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.19 + image: ghcr.io/nicotsx/zerobyte:v0.21 container_name: zerobyte restart: unless-stopped ports: @@ -113,7 +113,7 @@ If you want to track a local directory on the same server where Zerobyte is runn ```diff services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.19 + image: ghcr.io/nicotsx/zerobyte:v0.21 container_name: zerobyte restart: unless-stopped cap_add: @@ -186,7 +186,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov ```diff services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.19 + image: ghcr.io/nicotsx/zerobyte:v0.21 container_name: zerobyte restart: unless-stopped cap_add: diff --git a/app/app.css b/app/app.css index c84370ed..db5fb356 100644 --- a/app/app.css +++ b/app/app.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin "@tailwindcss/typography"; @import "tw-animate-css"; @import "dither-plugin"; diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index aa9319dc..b2274781 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, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, 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, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, 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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getUpdates, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, 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, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, 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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -439,6 +439,23 @@ export const updateRepositoryMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSnapshots({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listSnapshotsQueryKey = (options: Options) => createQueryKey('listSnapshots', options); /** @@ -544,6 +561,23 @@ export const doctorRepositoryMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await tagSnapshots({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey('listBackupSchedules', options); /** @@ -927,6 +961,24 @@ export const getSystemInfoOptions = (options?: Options) => qu queryKey: getSystemInfoQueryKey(options) }); +export const getUpdatesQueryKey = (options?: Options) => createQueryKey('getUpdates', options); + +/** + * Check for application updates from GitHub + */ +export const getUpdatesOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getUpdates({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getUpdatesQueryKey(options) +}); + /** * Download the Restic password file for backup recovery. Requires password re-authentication. */ diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 8af17c3e..866fcc3d 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, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, 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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, 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, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -189,6 +189,18 @@ export const updateRepository = (options: } }); +/** + * Delete multiple snapshots from a repository + */ +export const deleteSnapshots = (options: Options) => (options.client ?? client).delete({ + url: '/api/v1/repositories/{id}/snapshots', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * List all snapshots in a repository */ @@ -226,6 +238,18 @@ export const restoreSnapshot = (options: O */ export const doctorRepository = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{id}/doctor', ...options }); +/** + * Tag multiple snapshots in a repository + */ +export const tagSnapshots = (options: Options) => (options.client ?? client).post({ + url: '/api/v1/repositories/{id}/snapshots/tag', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * List all backup schedules */ @@ -385,6 +409,11 @@ export const testNotificationDestination = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/info', ...options }); +/** + * Check for application updates from GitHub + */ +export const getUpdates = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/updates', ...options }); + /** * Download the Restic password file for backup recovery. Requires password re-authentication. */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 83d2a674..6e757eec 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -798,7 +798,9 @@ export type ListRepositoriesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -806,39 +808,51 @@ export type ListRepositoriesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -851,7 +865,9 @@ export type ListRepositoriesResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -877,7 +893,9 @@ export type CreateRepositoryData = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -885,39 +903,51 @@ export type CreateRepositoryData = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -930,7 +960,9 @@ export type CreateRepositoryData = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1018,7 +1050,9 @@ export type GetRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1026,39 +1060,51 @@ export type GetRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1071,7 +1117,9 @@ export type GetRepositoryResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1124,7 +1172,9 @@ export type UpdateRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1132,39 +1182,51 @@ export type UpdateRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1177,7 +1239,9 @@ export type UpdateRepositoryResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1195,6 +1259,28 @@ export type UpdateRepositoryResponses = { export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses]; +export type DeleteSnapshotsData = { + body?: { + snapshotIds: Array; + }; + path: { + id: string; + }; + query?: never; + url: '/api/v1/repositories/{id}/snapshots'; +}; + +export type DeleteSnapshotsResponses = { + /** + * Snapshots deleted successfully + */ + 200: { + message: string; + }; +}; + +export type DeleteSnapshotsResponse = DeleteSnapshotsResponses[keyof DeleteSnapshotsResponses]; + export type ListSnapshotsData = { body?: never; path: { @@ -1367,6 +1453,31 @@ export type DoctorRepositoryResponses = { export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses]; +export type TagSnapshotsData = { + body?: { + snapshotIds: Array; + add?: Array; + remove?: Array; + set?: Array; + }; + path: { + id: string; + }; + query?: never; + url: '/api/v1/repositories/{id}/snapshots/tag'; +}; + +export type TagSnapshotsResponses = { + /** + * Snapshots tagged successfully + */ + 200: { + message: string; + }; +}; + +export type TagSnapshotsResponse = TagSnapshotsResponses[keyof TagSnapshotsResponses]; + export type ListBackupSchedulesData = { body?: never; path?: never; @@ -1400,7 +1511,9 @@ export type ListBackupSchedulesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1408,39 +1521,51 @@ export type ListBackupSchedulesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1453,7 +1578,9 @@ export type ListBackupSchedulesResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1662,7 +1789,9 @@ export type GetBackupScheduleResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1670,39 +1799,51 @@ export type GetBackupScheduleResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1715,7 +1856,9 @@ export type GetBackupScheduleResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1905,7 +2048,9 @@ export type GetBackupScheduleForVolumeResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1913,39 +2058,51 @@ export type GetBackupScheduleForVolumeResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1958,7 +2115,9 @@ export type GetBackupScheduleForVolumeResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -2151,10 +2310,20 @@ export type GetScheduleNotificationsResponses = { useTLS: boolean; password?: string; username?: string; + } | { + method: 'GET' | 'POST'; + type: 'generic'; + url: string; + contentType?: string; + headers?: Array; + messageKey?: string; + titleKey?: string; + useJson?: boolean; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2184,7 +2353,7 @@ export type GetScheduleNotificationsResponses = { enabled: boolean; id: number; name: string; - type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; + type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; updatedAt: number; }; destinationId: number; @@ -2241,10 +2410,20 @@ export type UpdateScheduleNotificationsResponses = { useTLS: boolean; password?: string; username?: string; + } | { + method: 'GET' | 'POST'; + type: 'generic'; + url: string; + contentType?: string; + headers?: Array; + messageKey?: string; + titleKey?: string; + useJson?: boolean; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2274,7 +2453,7 @@ export type UpdateScheduleNotificationsResponses = { enabled: boolean; id: number; name: string; - type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; + type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; updatedAt: number; }; destinationId: number; @@ -2315,7 +2494,9 @@ export type GetScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -2323,39 +2504,51 @@ export type GetScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -2368,7 +2561,9 @@ export type GetScheduleMirrorsResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -2421,7 +2616,9 @@ export type UpdateScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -2429,39 +2626,51 @@ export type UpdateScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + cacert?: string; customPassword?: string; endpointSuffix?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'local'; name: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; path?: string; } | { backend: 'rclone'; path: string; remote: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -2474,7 +2683,9 @@ export type UpdateScheduleMirrorsResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -2568,10 +2779,20 @@ export type ListNotificationDestinationsResponses = { useTLS: boolean; password?: string; username?: string; + } | { + method: 'GET' | 'POST'; + type: 'generic'; + url: string; + contentType?: string; + headers?: Array; + messageKey?: string; + titleKey?: string; + useJson?: boolean; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2601,7 +2822,7 @@ export type ListNotificationDestinationsResponses = { enabled: boolean; id: number; name: string; - type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; + type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; updatedAt: number; }>; }; @@ -2629,10 +2850,20 @@ export type CreateNotificationDestinationData = { useTLS: boolean; password?: string; username?: string; + } | { + method: 'GET' | 'POST'; + type: 'generic'; + url: string; + contentType?: string; + headers?: Array; + messageKey?: string; + titleKey?: string; + useJson?: boolean; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2689,10 +2920,20 @@ export type CreateNotificationDestinationResponses = { useTLS: boolean; password?: string; username?: string; + } | { + method: 'GET' | 'POST'; + type: 'generic'; + url: string; + contentType?: string; + headers?: Array; + messageKey?: string; + titleKey?: string; + useJson?: boolean; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2722,7 +2963,7 @@ export type CreateNotificationDestinationResponses = { enabled: boolean; id: number; name: string; - type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; + type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; updatedAt: number; }; }; @@ -2796,10 +3037,20 @@ export type GetNotificationDestinationResponses = { useTLS: boolean; password?: string; username?: string; + } | { + method: 'GET' | 'POST'; + type: 'generic'; + url: string; + contentType?: string; + headers?: Array; + messageKey?: string; + titleKey?: string; + useJson?: boolean; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2829,7 +3080,7 @@ export type GetNotificationDestinationResponses = { enabled: boolean; id: number; name: string; - type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; + type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; updatedAt: number; }; }; @@ -2857,10 +3108,20 @@ export type UpdateNotificationDestinationData = { useTLS: boolean; password?: string; username?: string; + } | { + method: 'GET' | 'POST'; + type: 'generic'; + url: string; + contentType?: string; + headers?: Array; + messageKey?: string; + titleKey?: string; + useJson?: boolean; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2927,10 +3188,20 @@ export type UpdateNotificationDestinationResponses = { useTLS: boolean; password?: string; username?: string; + } | { + method: 'GET' | 'POST'; + type: 'generic'; + url: string; + contentType?: string; + headers?: Array; + messageKey?: string; + titleKey?: string; + useJson?: boolean; } | { priority: 'default' | 'high' | 'low' | 'max' | 'min'; topic: string; type: 'ntfy'; + accessToken?: string; password?: string; serverUrl?: string; username?: string; @@ -2960,7 +3231,7 @@ export type UpdateNotificationDestinationResponses = { enabled: boolean; id: number; name: string; - type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; + type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram'; updatedAt: number; }; }; @@ -3023,6 +3294,32 @@ export type GetSystemInfoResponses = { export type GetSystemInfoResponse = GetSystemInfoResponses[keyof GetSystemInfoResponses]; +export type GetUpdatesData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/system/updates'; +}; + +export type GetUpdatesResponses = { + /** + * Update information and missed releases + */ + 200: { + currentVersion: string; + hasUpdate: boolean; + latestVersion: string; + missedReleases: Array<{ + body: string; + publishedAt: string; + url: string; + version: string; + }>; + }; +}; + +export type GetUpdatesResponse = GetUpdatesResponses[keyof GetUpdatesResponses]; + export type DownloadResticPasswordData = { body?: { password: string; diff --git a/app/client/components/__test__/file-tree.test.tsx b/app/client/components/__test__/file-tree.test.tsx new file mode 100644 index 00000000..7589fe63 --- /dev/null +++ b/app/client/components/__test__/file-tree.test.tsx @@ -0,0 +1,166 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */ +import { expect, test, describe } from "bun:test"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FileTree, type FileEntry } from "../file-tree"; + +describe("FileTree Selection Logic", () => { + const testFiles: FileEntry[] = [ + { name: "root", path: "/root", type: "folder" }, + { name: "photos", path: "/root/photos", type: "folder" }, + { name: "backups", path: "/root/photos/backups", type: "folder" }, + { name: "library", path: "/root/photos/library", type: "folder" }, + { name: "profile", path: "/root/photos/profile", type: "folder" }, + { name: "upload", path: "/root/photos/upload", type: "folder" }, + ]; + + test("selecting a folder simplifies to parent if it's the only child", async () => { + let currentSelection = new Set(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + f.path))} + />, + ); + + const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]'); + expect(photosCheckbox).toBeTruthy(); + + fireEvent.click(photosCheckbox!); + + expect(currentSelection.has("/root")).toBe(true); + expect(currentSelection.size).toBe(1); + }); + + test("unselecting a child removes the parent from selection", async () => { + let currentSelection = new Set(["/root"]); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + f.path))} + />, + ); + + const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]'); + fireEvent.click(libraryCheckbox!); + + expect(currentSelection.has("/root")).toBe(false); + expect(currentSelection.has("/root/photos")).toBe(false); + + expect(currentSelection.has("/root/photos/backups")).toBe(true); + expect(currentSelection.has("/root/photos/profile")).toBe(true); + expect(currentSelection.has("/root/photos/upload")).toBe(true); + expect(currentSelection.size).toBe(3); + }); + + test("recursive simplification when all children are selected", async () => { + let currentSelection = new Set(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + const { rerender } = render( + f.path))} + />, + ); + + const children = ["backups", "library", "profile", "upload"]; + + for (const name of children) { + const checkbox = screen.getByText(name).parentElement?.querySelector('button[role="checkbox"]'); + fireEvent.click(checkbox!); + + rerender( + f.path))} + />, + ); + } + + expect(currentSelection.has("/root")).toBe(true); + expect(currentSelection.size).toBe(1); + }); + + test("does not simplify to parent if not all children are selected", async () => { + const multipleFiles: FileEntry[] = [ + { name: "root", path: "/root", type: "folder" }, + { name: "child1", path: "/root/child1", type: "folder" }, + { name: "child2", path: "/root/child2", type: "folder" }, + ]; + + let currentSelection = new Set(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + f.path))} + />, + ); + + const child1Checkbox = screen.getByText("child1").parentElement?.querySelector('button[role="checkbox"]'); + fireEvent.click(child1Checkbox!); + + expect(currentSelection.has("/root/child1")).toBe(true); + expect(currentSelection.has("/root")).toBe(false); + expect(currentSelection.size).toBe(1); + }); + + test("simplifies existing deep paths when parent is selected", async () => { + const files: FileEntry[] = [ + { name: "hello", path: "/hello", type: "folder" }, + { name: "hello_prev", path: "/hello_prev", type: "folder" }, + { name: "service", path: "/service", type: "folder" }, + ]; + + let currentSelection = new Set(["/hello", "/hello_prev", "/service/app/data/upload"]); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + , + ); + + const serviceCheckbox = screen.getByText("service").parentElement?.querySelector('button[role="checkbox"]'); + expect(serviceCheckbox).toBeTruthy(); + + fireEvent.click(serviceCheckbox!); + + expect(currentSelection.has("/service")).toBe(true); + expect(currentSelection.has("/service/app/data/upload")).toBe(false); + expect(currentSelection.size).toBe(3); // /hello, /hello_prev, /service + }); +}); diff --git a/app/client/components/app-breadcrumb.tsx b/app/client/components/app-breadcrumb.tsx index b6c32b29..6f602f62 100644 --- a/app/client/components/app-breadcrumb.tsx +++ b/app/client/components/app-breadcrumb.tsx @@ -38,7 +38,7 @@ export function AppBreadcrumb() { } return ( - + {breadcrumbs.map((breadcrumb, index) => { const isLast = index === breadcrumbs.length - 1; diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index 7796b467..c42ed4f7 100644 --- a/app/client/components/app-sidebar.tsx +++ b/app/client/components/app-sidebar.tsx @@ -1,5 +1,6 @@ import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react"; import { Link, NavLink } from "react-router"; +import { useState } from "react"; import { Sidebar, SidebarContent, @@ -15,6 +16,8 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip"; import { cn } from "~/client/lib/utils"; import { APP_VERSION } from "~/client/lib/version"; +import { useUpdates } from "~/client/hooks/use-updates"; +import { ReleaseNotesDialog } from "./release-notes-dialog"; const items = [ { @@ -46,6 +49,9 @@ const items = [ export function AppSidebar() { const { state } = useSidebar(); + const { updates, hasUpdate } = useUpdates(); + const [showReleaseNotes, setShowReleaseNotes] = useState(false); + const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`; const releaseUrl = APP_VERSION === "dev" @@ -98,16 +104,28 @@ export function AppSidebar() { - - {displayVersion} - +
+ + {displayVersion} + + {hasUpdate && state !== "collapsed" && ( + + )} +
+
); diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx index 7789c821..fa9559c2 100644 --- a/app/client/components/file-tree.tsx +++ b/app/client/components/file-tree.tsx @@ -116,13 +116,15 @@ export const FileTree = memo((props: Props) => { // Add new folders to collapsed set when file list changes useEffect(() => { setCollapsedFolders((prevSet) => { + let hasChanges = false; const newSet = new Set(prevSet); for (const item of fileList) { if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) { newSet.add(item.fullPath); + hasChanges = true; } } - return newSet; + return hasChanges ? newSet : prevSet; }); }, [fileList, expandedFolders]); @@ -149,9 +151,9 @@ export const FileTree = memo((props: Props) => { newSelection.add(path); // Remove any descendants from selection since parent now covers them - for (const item of fileList) { - if (item.fullPath.startsWith(`${path}/`)) { - newSelection.delete(item.fullPath); + for (const selectedPath of newSelection) { + if (selectedPath.startsWith(`${path}/`)) { + newSelection.delete(selectedPath); } } } else { @@ -182,7 +184,8 @@ export const FileTree = memo((props: Props) => { if ( item.fullPath.startsWith(`${selectedParentPath}/`) && !item.fullPath.startsWith(`${path}/`) && - item.fullPath !== path + item.fullPath !== path && + !path.startsWith(`${item.fullPath}/`) ) { newSelection.add(item.fullPath); } @@ -190,39 +193,45 @@ export const FileTree = memo((props: Props) => { } } - const childrenByParent = new Map(); - for (const selectedPath of newSelection) { - const lastSlashIndex = selectedPath.lastIndexOf("/"); - if (lastSlashIndex > 0) { - const parentPath = selectedPath.slice(0, lastSlashIndex); - if (!childrenByParent.has(parentPath)) { - childrenByParent.set(parentPath, []); - } - childrenByParent.get(parentPath)?.push(selectedPath); - } - } - - // For each parent, check if all its children are selected - for (const [parentPath, selectedChildren] of childrenByParent.entries()) { - // Get all children of this parent from the file list - const allChildren = fileList.filter((item) => { - const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/")); - return itemParentPath === parentPath; - }); - - // If all children are selected, replace them with the parent - if (allChildren.length > 0 && selectedChildren.length === allChildren.length) { - // Check that we have every child - const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath)); - const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c)); - - if (allChildrenSelected) { - // Remove all children - for (const childPath of selectedChildren) { - newSelection.delete(childPath); + let changed = true; + while (changed) { + changed = false; + const childrenByParent = new Map(); + for (const selectedPath of newSelection) { + const lastSlashIndex = selectedPath.lastIndexOf("/"); + if (lastSlashIndex > 0) { + const parentPath = selectedPath.slice(0, lastSlashIndex); + if (!childrenByParent.has(parentPath)) { + childrenByParent.set(parentPath, []); + } + childrenByParent.get(parentPath)?.push(selectedPath); + } + } + + // For each parent, check if all its children are selected + for (const [parentPath, selectedChildren] of childrenByParent.entries()) { + // Get all children of this parent from the file list + const allChildren = fileList.filter((item) => { + const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/")); + return itemParentPath === parentPath; + }); + + // If all children are selected, replace them with the parent + if (allChildren.length > 0 && selectedChildren.length === allChildren.length) { + // Check that we have every child + const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath)); + const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c)); + + if (allChildrenSelected) { + // Remove all children + for (const childPath of selectedChildren) { + newSelection.delete(childPath); + } + // Add the parent + newSelection.add(parentPath); + changed = true; + break; } - // Add the parent - newSelection.add(parentPath); } } } @@ -481,8 +490,9 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]); return ( - + ); }); diff --git a/app/client/components/layout.tsx b/app/client/components/layout.tsx index 8142c252..6030391d 100644 --- a/app/client/components/layout.tsx +++ b/app/client/components/layout.tsx @@ -43,8 +43,8 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
-
-
+
+
@@ -76,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
-
+
diff --git a/app/client/components/release-notes-dialog.tsx b/app/client/components/release-notes-dialog.tsx new file mode 100644 index 00000000..81e1f765 --- /dev/null +++ b/app/client/components/release-notes-dialog.tsx @@ -0,0 +1,44 @@ +import { format } from "date-fns"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog"; +import { ScrollArea } from "~/client/components/ui/scroll-area"; +import type { UpdateInfoDto } from "~/server/modules/system/system.dto"; + +interface ReleaseNotesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + updates: UpdateInfoDto | undefined; +} + +export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) { + if (!updates) return null; + + return ( + + + + Release Notes + + Current version: {updates.currentVersion} • Latest version: {updates.latestVersion} + + + +
+ {updates.missedReleases.map((release) => ( +
+
+

{release.version}

+ {format(new Date(release.publishedAt), "PPP")} +
+
+ {release.body} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index dfee2792..0f8574bb 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Calendar, Clock, Database, HardDrive, Server, Trash2 } from "lucide-react"; +import { Calendar, Clock, Database, HardDrive, Tag, Trash2, X } from "lucide-react"; import { Link, useNavigate } from "react-router"; import { toast } from "sonner"; import { ByteSize } from "~/client/components/bytes-size"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Button } from "~/client/components/ui/button"; +import { Checkbox } from "~/client/components/ui/checkbox"; import { AlertDialog, AlertDialogAction, @@ -16,8 +17,17 @@ import { AlertDialogHeader, AlertDialogTitle, } from "~/client/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/client/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { formatDuration } from "~/utils/utils"; -import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { deleteSnapshotsMutation, tagSnapshotsMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; import type { BackupSchedule, Snapshot } from "../lib/types"; import { cn } from "../lib/utils"; @@ -31,68 +41,126 @@ type Props = { export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { const navigate = useNavigate(); const queryClient = useQueryClient(); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [snapshotToDelete, setSnapshotToDelete] = useState(null); - const deleteSnapshot = useMutation({ - ...deleteSnapshotMutation(), + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); + const [showReTagDialog, setShowReTagDialog] = useState(false); + const [targetScheduleId, setTargetScheduleId] = useState(""); + + const deleteSnapshots = useMutation({ + ...deleteSnapshotsMutation(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["listSnapshots"] }); - setShowDeleteConfirm(false); - setSnapshotToDelete(null); + setShowBulkDeleteConfirm(false); + setSelectedIds(new Set()); }, }); - const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => { - e.stopPropagation(); - setSnapshotToDelete(snapshotId); - setShowDeleteConfirm(true); - }; - - const handleConfirmDelete = () => { - if (snapshotToDelete) { - toast.promise( - deleteSnapshot.mutateAsync({ - path: { id: repositoryId, snapshotId: snapshotToDelete }, - }), - { - loading: "Deleting snapshot...", - success: "Snapshot deleted successfully", - error: (error) => parseError(error)?.message || "Failed to delete snapshot", - }, - ); - } - }; + const tagSnapshots = useMutation({ + ...tagSnapshotsMutation(), + onMutate: () => { + setShowReTagDialog(false); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listSnapshots"] }); + setShowReTagDialog(false); + setSelectedIds(new Set()); + setTargetScheduleId(""); + }, + }); const handleRowClick = (snapshotId: string) => { navigate(`/repositories/${repositoryId}/${snapshotId}`); }; + const toggleSelectAll = () => { + if (selectedIds.size === snapshots.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(snapshots.map((s) => s.short_id))); + } + }; + + const handleBulkDelete = () => { + toast.promise( + deleteSnapshots.mutateAsync({ + path: { id: repositoryId }, + body: { snapshotIds: Array.from(selectedIds) }, + }), + { + loading: `Deleting ${selectedIds.size} snapshots...`, + success: "Snapshots deleted successfully", + error: (error) => parseError(error)?.message || "Failed to delete snapshots", + }, + ); + }; + + const handleBulkReTag = () => { + const schedule = backups.find((b) => String(b.id) === targetScheduleId); + if (!schedule) return; + + toast.promise( + tagSnapshots.mutateAsync({ + path: { id: repositoryId }, + body: { + snapshotIds: Array.from(selectedIds), + set: [schedule.shortId], + }, + }), + { + loading: `Re-tagging ${selectedIds.size} snapshots...`, + success: `Snapshots re-tagged to ${schedule.name}`, + error: (error) => parseError(error)?.message || "Failed to re-tag snapshots", + }, + ); + }; + return ( <> -
+
+ + 0} + onCheckedChange={toggleSelectAll} + aria-label="Select all" + /> + Snapshot ID Schedule Date & Time Size Duration - Volume - Actions {snapshots.map((snapshot) => { const backup = backups.find((b) => snapshot.tags.includes(b.shortId)); + const isSelected = selectedIds.has(snapshot.short_id); return ( handleRowClick(snapshot.short_id)} > + e.stopPropagation()}> + { + const newSelected = new Set(selectedIds); + if (newSelected.has(snapshot.short_id)) { + newSelected.delete(snapshot.short_id); + } else { + newSelected.add(snapshot.short_id); + } + setSelectedIds(newSelected); + }} + aria-label={`Select snapshot ${snapshot.short_id}`} + /> +
@@ -134,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { {formatDuration(snapshot.duration / 1000)}
- -
- - e.stopPropagation()} - className="hover:underline" - > - {backup ? backup.volume.name : "-"} - - -
-
- - -
); })} @@ -167,27 +209,99 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
- + {selectedIds.size > 0 && ( +
+
+
+ + {selectedIds.size} selected +
+
+ + +
+
+
+ )} + + - Delete snapshot? + Delete {selectedIds.size} snapshots? - This action cannot be undone. This will permanently delete the snapshot and all its data from the - repository. + This action cannot be undone. This will permanently delete the selected snapshots and all their data from + the repository. Cancel - Delete snapshot + Delete {selectedIds.size} snapshots + + + + + Re-tag snapshots + + Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All {selectedIds.size}{" "} + selected snapshots will be associated with the chosen schedule. + + +
+ +
+ + + + +
+
); }; diff --git a/app/client/components/ui/breadcrumb.tsx b/app/client/components/ui/breadcrumb.tsx index 4d0d3eab..e1fb0843 100644 --- a/app/client/components/ui/breadcrumb.tsx +++ b/app/client/components/ui/breadcrumb.tsx @@ -4,8 +4,8 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"; import { cn } from "~/client/lib/utils"; -function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return
diff --git a/app/client/components/ui/collapsible.tsx b/app/client/components/ui/collapsible.tsx new file mode 100644 index 00000000..5e13272d --- /dev/null +++ b/app/client/components/ui/collapsible.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import { ChevronDown } from "lucide-react"; +import { cn } from "~/client/lib/utils"; + +interface CollapsibleProps extends React.HTMLAttributes { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; +} + +const CollapsibleContext = React.createContext<{ + open: boolean; + setOpen: React.Dispatch>; +}>({ + open: false, + setOpen: () => {}, +}); + +const Collapsible = React.forwardRef( + ({ className, open: controlledOpen, onOpenChange, defaultOpen = false, children, ...props }, ref) => { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); + + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : uncontrolledOpen; + + const setOpen = React.useCallback( + (value: React.SetStateAction) => { + const newValue = typeof value === "function" ? value(open) : value; + if (!isControlled) { + setUncontrolledOpen(newValue); + } + onOpenChange?.(newValue); + }, + [isControlled, open, onOpenChange], + ); + + return ( + +
+ {children} +
+
+ ); + }, +); +Collapsible.displayName = "Collapsible"; + +interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes {} + +const CollapsibleTrigger = React.forwardRef( + ({ className, children, ...props }, ref) => { + const { open, setOpen } = React.useContext(CollapsibleContext); + + return ( + + ); + }, +); +CollapsibleTrigger.displayName = "CollapsibleTrigger"; + +interface CollapsibleContentProps extends React.HTMLAttributes {} + +const CollapsibleContent = React.forwardRef( + ({ className, children, ...props }, ref) => { + const { open } = React.useContext(CollapsibleContext); + + return ( + + ); + }, +); +CollapsibleContent.displayName = "CollapsibleContent"; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/app/client/components/volume-file-browser.tsx b/app/client/components/volume-file-browser.tsx index 84ea09e5..6e9cd53e 100644 --- a/app/client/components/volume-file-browser.tsx +++ b/app/client/components/volume-file-browser.tsx @@ -57,7 +57,7 @@ export const VolumeFileBrowser = ({ if (fileBrowser.isLoading) { return ( -
+

Loading files...

); @@ -65,7 +65,7 @@ export const VolumeFileBrowser = ({ if (error) { return ( -
+

Failed to load files: {(error as Error).message}

); @@ -73,7 +73,7 @@ export const VolumeFileBrowser = ({ if (fileBrowser.isEmpty) { return ( -
+

{emptyMessage}

{emptyDescription &&

{emptyDescription}

} diff --git a/app/client/hooks/use-updates.ts b/app/client/hooks/use-updates.ts new file mode 100644 index 00000000..04becb23 --- /dev/null +++ b/app/client/hooks/use-updates.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { getUpdatesOptions } from "../api-client/@tanstack/react-query.gen"; + +export function useUpdates() { + const { data, isLoading, error } = useQuery({ + ...getUpdatesOptions(), + staleTime: 60 * 60 * 1000, + gcTime: 24 * 60 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + return { + updates: data, + hasUpdate: data?.hasUpdate ?? false, + isLoading, + error, + }; +} diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 1d94ff17..63d3a8e0 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -2,6 +2,7 @@ import { arktypeResolver } from "@hookform/resolvers/arktype"; import { useQuery } from "@tanstack/react-query"; import { type } from "arktype"; +import { X } from "lucide-react"; import { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen"; @@ -169,6 +170,16 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: [form], ); + const handleRemovePath = useCallback( + (pathToRemove: string) => { + const newPaths = new Set(selectedPaths); + newPaths.delete(pathToRemove); + setSelectedPaths(newPaths); + form.setValue("includePatterns", Array.from(newPaths)); + }, + [selectedPaths, form], + ); + return (
{volume.name} to a secure repository. - + ( - + Backup name @@ -204,7 +215,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: control={form.control} name="repositoryId" render={({ field }) => ( - + Backup repository @@ -316,7 +327,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: control={form.control} name="monthlyDays" render={({ field }) => ( - + Days of the month
@@ -373,8 +384,19 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:

Selected paths:

{Array.from(selectedPaths).map((path) => ( - + {path} + ))}
@@ -490,7 +512,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Retention policy Define how many snapshots to keep. Leave empty to keep all. - + Mirror Repositories - + Configure secondary repositories where snapshots will be automatically copied after each backup
@@ -270,9 +270,9 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit Repository - Enabled - Last Copy - + Enabled + Last Copy + diff --git a/app/client/modules/backups/components/schedule-notifications-config.tsx b/app/client/modules/backups/components/schedule-notifications-config.tsx index 820e72b7..0a1d4f37 100644 --- a/app/client/modules/backups/components/schedule-notifications-config.tsx +++ b/app/client/modules/backups/components/schedule-notifications-config.tsx @@ -152,7 +152,9 @@ export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props) Notifications - Configure which notifications to send for this backup schedule + + Configure which notifications to send for this backup schedule +
{!isAddingNew && availableDestinations.length > 0 && ( ) : ( - )} {schedule.retentionPolicy && ( @@ -123,28 +123,28 @@ export const ScheduleSummary = (props: Props) => { size="sm" loading={runForget.isPending} onClick={() => setShowForgetConfirm(true)} - className="w-full sm:w-auto" + className="w-full @md:w-auto" > - Run cleanup + Run cleanup )} -
- +

Schedule

{summary.scheduleLabel}

@@ -178,19 +178,21 @@ export const ScheduleSummary = (props: Props) => {
{schedule.lastBackupStatus === "warning" && ( -
+

Warning Details

-

+

{schedule.lastBackupError ?? "Last backup completed with warnings. Check your container logs for more details."}

)} - {schedule.lastBackupError && ( -
-

Error Details

-

{schedule.lastBackupError}

+ {schedule.lastBackupError && schedule.lastBackupStatus === "error" && ( +
+

Error details

+

+ {schedule.lastBackupError} +

)} diff --git a/app/client/modules/backups/components/snapshot-timeline.tsx b/app/client/modules/backups/components/snapshot-timeline.tsx index 4bdce96c..7ed68ef4 100644 --- a/app/client/modules/backups/components/snapshot-timeline.tsx +++ b/app/client/modules/backups/components/snapshot-timeline.tsx @@ -1,5 +1,5 @@ import { cn } from "~/client/lib/utils"; -import { Card } from "~/client/components/ui/card"; +import { Card, CardContent } from "~/client/components/ui/card"; import { ByteSize } from "~/client/components/bytes-size"; import { useEffect } from "react"; import type { ListSnapshotsResponse } from "~/client/api-client"; @@ -24,9 +24,9 @@ export const SnapshotTimeline = (props: Props) => { if (error) { return ( -
-

Error loading snapshots: {error}

-
+ +

{error}

+
); } diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx index 76f0c714..49b5ca54 100644 --- a/app/client/modules/backups/routes/backups.tsx +++ b/app/client/modules/backups/routes/backups.tsx @@ -117,7 +117,7 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
-
+
{items.map((id) => { const schedule = scheduleMap.get(id); if (!schedule) return null; diff --git a/app/client/modules/notifications/components/create-notification-form.tsx b/app/client/modules/notifications/components/create-notification-form.tsx index 63697c13..8b913664 100644 --- a/app/client/modules/notifications/components/create-notification-form.tsx +++ b/app/client/modules/notifications/components/create-notification-form.tsx @@ -14,10 +14,19 @@ import { FormMessage, } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; -import { SecretInput } from "~/client/components/ui/secret-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; -import { Checkbox } from "~/client/components/ui/checkbox"; import { notificationConfigSchemaBase } from "~/schemas/notifications"; +import { + CustomForm, + DiscordForm, + EmailForm, + GenericForm, + GotifyForm, + NtfyForm, + PushoverForm, + SlackForm, + TelegramForm, +} from "./notification-forms"; export const formSchema = type({ name: "2<=string<=32", @@ -48,6 +57,9 @@ const defaultValuesForType = { slack: { type: "slack" as const, webhookUrl: "", + channel: "", + username: "", + iconEmoji: "", }, discord: { type: "discord" as const, @@ -75,6 +87,16 @@ const defaultValuesForType = { botToken: "", chatId: "", }, + generic: { + type: "generic" as const, + url: "", + method: "POST" as const, + contentType: "application/json", + headers: [], + useJson: true, + titleKey: "title", + messageKey: "message", + }, custom: { type: "custom" as const, shoutrrrUrl: "", @@ -84,7 +106,9 @@ const defaultValuesForType = { export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => { const form = useForm({ resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), - defaultValues: initialValues, + defaultValues: initialValues || { + name: "", + }, resetOptions: { keepDefaultValues: true, keepDirtyValues: false, @@ -97,7 +121,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue useEffect(() => { if (!initialValues) { form.reset({ - name: form.getValues().name, + name: form.getValues().name || "", ...defaultValuesForType[watchedType as keyof typeof defaultValuesForType], }); } @@ -152,6 +176,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue Ntfy Pushover Telegram + Generic Webhook Custom (Shoutrrr URL) @@ -161,545 +186,15 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue )} /> - {watchedType === "email" && ( - <> - ( - - SMTP Host - - - - - - )} - /> - ( - - SMTP Port - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> - ( - - Username (Optional) - - - - - - )} - /> - ( - - Password (Optional) - - - - - - )} - /> - ( - - 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) - - - - - - )} - /> - ( - - Thread ID (Optional) - - - - - ID of the thread to post messages in. Leave empty to post in the main channel. - - - - )} - /> - - )} - - {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). - - - )} - /> - ( - - Path (Optional) - - - - Custom path on the Gotify server, if applicable. - - - )} - /> - - )} - - {watchedType === "ntfy" && ( - <> - ( - - Server URL (Optional) - - - - Leave empty to use ntfy.sh public service. - - - )} - /> - ( - - Topic - - - - The ntfy topic name to publish to. - - - )} - /> - ( - - Username (Optional) - - - - Username for server authentication, if required. - - - )} - /> - ( - - Password (Optional) - - - - Password for server authentication, if required. - - - )} - /> - ( - - Access token (Optional) - - - - - Access token for server authentication. Will take precedence over username/password if set. - - - - )} - /> - ( - - Priority - - - - )} - /> - - )} - - {watchedType === "pushover" && ( - <> - ( - - User Key - - - - Your Pushover user key from the dashboard. - - - )} - /> - ( - - API Token - - - - Application API token from your Pushover application. - - - )} - /> - ( - - Devices (Optional) - - - - Comma-separated list of device names. Leave empty for all devices. - - - )} - /> - ( - - Priority - - Message priority level. - - - )} - /> - - )} - - {watchedType === "telegram" && ( - <> - ( - - Bot Token - - - - - Telegram bot token. Get this from BotFather when you create your bot. - - - - )} - /> - ( - - Chat ID - - - - Telegram chat ID to send notifications to. - - - )} - /> - - )} - - {watchedType === "custom" && ( - ( - - Shoutrrr URL - - - - - Direct Shoutrrr URL for power users. See  - - Shoutrrr documentation - -  for supported services and URL formats. - - - - )} - /> - )} + {watchedType === "email" && } + {watchedType === "slack" && } + {watchedType === "discord" && } + {watchedType === "gotify" && } + {watchedType === "ntfy" && } + {watchedType === "pushover" && } + {watchedType === "telegram" && } + {watchedType === "generic" && } + {watchedType === "custom" && } ); diff --git a/app/client/modules/notifications/components/notification-forms/custom-form.tsx b/app/client/modules/notifications/components/notification-forms/custom-form.tsx new file mode 100644 index 00000000..babd7c40 --- /dev/null +++ b/app/client/modules/notifications/components/notification-forms/custom-form.tsx @@ -0,0 +1,41 @@ +import type { UseFormReturn } from "react-hook-form"; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; +import { Input } from "~/client/components/ui/input"; +import type { NotificationFormValues } from "../create-notification-form"; + +type Props = { + form: UseFormReturn; +}; + +export const CustomForm = ({ form }: Props) => { + return ( + ( + + Shoutrrr URL + + + + + Direct Shoutrrr URL for power users. See  + + Shoutrrr documentation + +  for supported services and URL formats. + + + + )} + /> + ); +}; diff --git a/app/client/modules/notifications/components/notification-forms/discord-form.tsx b/app/client/modules/notifications/components/notification-forms/discord-form.tsx new file mode 100644 index 00000000..a8d5fb35 --- /dev/null +++ b/app/client/modules/notifications/components/notification-forms/discord-form.tsx @@ -0,0 +1,71 @@ +import type { UseFormReturn } from "react-hook-form"; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; +import { Input } from "~/client/components/ui/input"; +import type { NotificationFormValues } from "../create-notification-form"; + +type Props = { + form: UseFormReturn; +}; + +export const DiscordForm = ({ form }: Props) => { + return ( + <> + ( + + Webhook URL + + + + Get this from your Discord server's Integrations settings. + + + )} + /> + ( + + Bot Username (Optional) + + + + + + )} + /> + ( + + Avatar URL (Optional) + + + + + + )} + /> + ( + + Thread ID (Optional) + + + + + ID of the thread to post messages in. Leave empty to post in the main channel. + + + + )} + /> + + ); +}; diff --git a/app/client/modules/notifications/components/notification-forms/email-form.tsx b/app/client/modules/notifications/components/notification-forms/email-form.tsx new file mode 100644 index 00000000..43a7daf8 --- /dev/null +++ b/app/client/modules/notifications/components/notification-forms/email-form.tsx @@ -0,0 +1,128 @@ +import type { UseFormReturn } from "react-hook-form"; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; +import { Input } from "~/client/components/ui/input"; +import { SecretInput } from "~/client/components/ui/secret-input"; +import { Checkbox } from "~/client/components/ui/checkbox"; +import type { NotificationFormValues } from "../create-notification-form"; + +type Props = { + form: UseFormReturn; +}; + +export const EmailForm = ({ form }: Props) => { + return ( + <> + ( + + SMTP Host + + + + + + )} + /> + ( + + SMTP Port + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + ( + + Username (Optional) + + + + + + )} + /> + ( + + Password (Optional) + + + + + + )} + /> + ( + + From Address + + + + + + )} + /> + ( + + To Addresses + + + field.onChange( + e.target.value + .split(",") + .map((email) => email.trim()) + .filter(Boolean), + ) + } + /> + + Comma-separated list of recipient email addresses. + + + )} + /> + ( + + + + +
+ Use TLS + Enable TLS encryption for SMTP connection. +
+
+ )} + /> + + ); +}; diff --git a/app/client/modules/notifications/components/notification-forms/generic-form.tsx b/app/client/modules/notifications/components/notification-forms/generic-form.tsx new file mode 100644 index 00000000..d969939c --- /dev/null +++ b/app/client/modules/notifications/components/notification-forms/generic-form.tsx @@ -0,0 +1,173 @@ +import type { UseFormReturn } from "react-hook-form"; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; +import { Input } from "~/client/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; +import { Checkbox } from "~/client/components/ui/checkbox"; +import { Textarea } from "~/client/components/ui/textarea"; +import { CodeBlock } from "~/client/components/ui/code-block"; +import { Label } from "~/client/components/ui/label"; +import type { NotificationFormValues } from "../create-notification-form"; + +type Props = { + form: UseFormReturn; +}; + +const WebhookPreview = ({ values }: { values: Partial }) => { + if (values.type !== "generic") return null; + + const contentType = values.contentType || "application/json"; + const headers = values.headers || []; + const useJson = values.useJson; + const titleKey = values.titleKey || "title"; + const messageKey = values.messageKey || "message"; + + let body = ""; + if (useJson) { + body = JSON.stringify( + { + [titleKey]: "Notification title", + [messageKey]: "Notification message", + }, + null, + 2, + ); + } else { + body = "Notification message"; + } + + const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""} + +${body}`; + + return ( +
+ + +

This is a preview of the HTTP request that will be sent.

+
+ ); +}; + +export const GenericForm = ({ form }: Props) => { + const watchedValues = form.watch(); + + return ( + <> + ( + + Webhook URL + + + + The target URL for the webhook. + + + )} + /> + ( + + Method + + + + )} + /> + ( + + Content Type + + + + + + )} + /> + ( + + Headers + +