diff --git a/.github/workflows/ci.yml b/.github/workflows/checks.yml similarity index 86% rename from .github/workflows/ci.yml rename to .github/workflows/checks.yml index 9a081d4..ba422b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/checks.yml @@ -1,9 +1,7 @@ name: Checks -permissions: - contents: read - on: + workflow_call: pull_request: branches: - main @@ -11,8 +9,11 @@ on: branches: - main +permissions: + contents: read + jobs: - ci: + checks: timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -24,6 +25,10 @@ jobs: - name: Install dependencies uses: "./.github/actions/install-dependencies" + - name: Run lint + shell: bash + run: bun run lint:ci + - name: Run type checks shell: bash run: bun run tsc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 326ffa0..48420d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,9 +31,12 @@ jobs: echo "release_type=release" >> $GITHUB_OUTPUT fi + checks: + uses: ./.github/workflows/checks.yml + build-images: timeout-minutes: 15 - needs: [determine-release-type] + needs: [determine-release-type, checks] runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/AGENTS.md b/AGENTS.md index 92f7ff1..9d09ac2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ ## Project Overview -Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). +Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). ## Technology Stack @@ -99,7 +99,7 @@ The server follows a modular service-oriented architecture: Each module follows a controller � service � database pattern: - `auth/` - User authentication and session management -- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, directories) +- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, SFTP, directories) - `repositories/` - Restic repository management (S3, Azure, GCS, local, rclone) - `backups/` - Backup schedule management and execution - `notifications/` - Notification system with multiple providers (Discord, email, Gotify, Ntfy, Slack, Pushover) @@ -109,7 +109,7 @@ Each module follows a controller � service � database pattern: - `lifecycle/` - Application startup/shutdown hooks **Backends** (`app/server/modules/backends/`): -Each volume backend (NFS, SMB, WebDAV, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2). +Each volume backend (NFS, SMB, WebDAV, SFTP, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2, sshfs). **Jobs** (`app/server/jobs/`): Cron-based background jobs managed by the Scheduler: @@ -158,7 +158,7 @@ Routes are organized in feature modules at `app/client/modules/*/routes/`. `app/schemas/` contains ArkType schemas used by both client and server: -- Volume configurations (NFS, SMB, WebDAV, directory) +- Volume configurations (NFS, SMB, WebDAV, SFTP, directory) - Repository configurations (S3, Azure, GCS, local, rclone) - Restic command output parsing types - Backend status types diff --git a/Dockerfile b/Dockerfile index c95dab9..c3448f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,9 @@ ARG BUN_VERSION="1.3.5" FROM oven/bun:${BUN_VERSION}-alpine AS base RUN apk upgrade --no-cache && \ - apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 + apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini + +ENTRYPOINT ["/sbin/tini", "-s", "--"] # ------------------------------ @@ -64,7 +66,7 @@ CMD ["bun", "run", "dev"] # ------------------------------ # PRODUCTION # ------------------------------ -FROM oven/bun:${BUN_VERSION} AS builder +FROM oven/bun:${BUN_VERSION}-alpine AS builder ARG APP_VERSION=dev diff --git a/README.md b/README.md index aef9976..4ceb816 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Zerobyte is a backup automation tool that helps you save your data across multip -   **Automated backups** with encryption, compression and retention policies powered by Restic -   **Flexible scheduling** For automated backup jobs with fine-grained retention policies -   **End-to-end encryption** ensuring your data is always protected --   **Multi-protocol support**: Backup from NFS, SMB, WebDAV, or local directories +-   **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories ## Installation @@ -94,7 +94,7 @@ services: - ✅ Improved security by reducing container capabilities - ✅ Support for local directories - ✅ Keep support all repository types (local, S3, GCS, Azure, rclone) -- ❌ Cannot mount NFS, SMB, or WebDAV shares directly from Zerobyte +- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`. @@ -104,7 +104,7 @@ See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly e ## Adding your first volume -Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor. +Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and local directories. A volume represents the source data you want to back up and monitor. To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings. diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index cb1ffea..f0e1b7d 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, 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, 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, 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, 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); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 6c1978c..a0e13c5 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, 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, 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 */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index b45285e..44b2b3c 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -169,6 +169,17 @@ export type ListVolumesResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -196,7 +207,7 @@ export type ListVolumesResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }>; }; @@ -221,6 +232,17 @@ export type CreateVolumeData = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -270,6 +292,17 @@ export type CreateVolumeResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -297,7 +330,7 @@ export type CreateVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -322,6 +355,17 @@ export type TestConnectionData = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -424,6 +468,17 @@ export type GetVolumeResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -451,7 +506,7 @@ export type GetVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -478,6 +533,17 @@ export type UpdateVolumeData = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -536,6 +602,17 @@ export type UpdateVolumeResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -563,7 +640,7 @@ export type UpdateVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -761,7 +838,9 @@ export type ListRepositoriesResponses = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -773,8 +852,10 @@ export type ListRepositoriesResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -838,7 +919,9 @@ export type CreateRepositoryData = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -850,8 +933,10 @@ export type CreateRepositoryData = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; name: string; compressionMode?: 'auto' | 'max' | 'off'; @@ -977,7 +1062,9 @@ export type GetRepositoryResponses = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -989,8 +1076,10 @@ export type GetRepositoryResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1081,7 +1170,9 @@ export type UpdateRepositoryResponses = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1093,8 +1184,10 @@ export type UpdateRepositoryResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1110,6 +1203,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: { @@ -1282,6 +1397,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; @@ -1355,7 +1495,9 @@ export type ListBackupSchedulesResponses = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1367,8 +1509,10 @@ export type ListBackupSchedulesResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1390,6 +1534,7 @@ export type ListBackupSchedulesResponses = { keepWithinDuration?: string; keepYearly?: number; } | null; + shortId: string; updatedAt: number; volume: { autoRemount: boolean; @@ -1409,6 +1554,17 @@ export type ListBackupSchedulesResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1436,7 +1592,7 @@ export type ListBackupSchedulesResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -1500,6 +1656,7 @@ export type CreateBackupScheduleResponses = { keepWithinDuration?: string; keepYearly?: number; } | null; + shortId: string; updatedAt: number; volumeId: number; }; @@ -1602,7 +1759,9 @@ export type GetBackupScheduleResponses = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1614,8 +1773,10 @@ export type GetBackupScheduleResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1637,6 +1798,7 @@ export type GetBackupScheduleResponses = { keepWithinDuration?: string; keepYearly?: number; } | null; + shortId: string; updatedAt: number; volume: { autoRemount: boolean; @@ -1656,6 +1818,17 @@ export type GetBackupScheduleResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1683,7 +1856,7 @@ export type GetBackupScheduleResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -1748,6 +1921,7 @@ export type UpdateBackupScheduleResponses = { keepWithinDuration?: string; keepYearly?: number; } | null; + shortId: string; updatedAt: number; volumeId: number; }; @@ -1830,7 +2004,9 @@ export type GetBackupScheduleForVolumeResponses = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -1842,8 +2018,10 @@ export type GetBackupScheduleForVolumeResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1865,6 +2043,7 @@ export type GetBackupScheduleForVolumeResponses = { keepWithinDuration?: string; keepYearly?: number; } | null; + shortId: string; updatedAt: number; volume: { autoRemount: boolean; @@ -1884,6 +2063,17 @@ export type GetBackupScheduleForVolumeResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1911,7 +2101,7 @@ export type GetBackupScheduleForVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -2022,10 +2212,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; @@ -2055,7 +2255,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; @@ -2112,10 +2312,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; @@ -2145,7 +2355,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; @@ -2226,7 +2436,9 @@ export type GetScheduleMirrorsResponses = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -2238,8 +2450,10 @@ export type GetScheduleMirrorsResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -2330,7 +2544,9 @@ export type UpdateScheduleMirrorsResponses = { } | { backend: 'rest'; url: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; password?: string; path?: string; @@ -2342,8 +2558,10 @@ export type UpdateScheduleMirrorsResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -2435,10 +2653,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; @@ -2468,7 +2696,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; }>; }; @@ -2496,10 +2724,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; @@ -2556,10 +2794,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; @@ -2589,7 +2837,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; }; }; @@ -2663,10 +2911,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; @@ -2696,7 +2954,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; }; }; @@ -2724,10 +2982,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; @@ -2794,10 +3062,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; @@ -2827,7 +3105,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; }; }; 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 0000000..7589fe6 --- /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 b6c32b2..6f602f6 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/file-tree.tsx b/app/client/components/file-tree.tsx index 7789c82..fa9559c 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 8142c25..6030391 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/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index 97a934b..0f8574b 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,69 +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 backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag)); - const backup = backups.find((b) => backupIds.includes(b.id)); + 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}`} + /> +
@@ -135,32 +202,6 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => { {formatDuration(snapshot.duration / 1000)}
- -
- - e.stopPropagation()} - className="hover:underline" - > - {backup ? backup.volume.name : "-"} - - -
-
- - -
); })} @@ -168,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 4d0d3ea..e1fb084 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 0000000..5e13272 --- /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 84ea09e..6e9cd53 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/components/volume-icon.tsx b/app/client/components/volume-icon.tsx index 3094d86..c21b3a0 100644 --- a/app/client/components/volume-icon.tsx +++ b/app/client/components/volume-icon.tsx @@ -1,53 +1,52 @@ -import { Cloud, Folder, Server, Share2 } from "lucide-react"; +import { Cloud, Folder, Server } from "lucide-react"; import type { BackendType } from "~/schemas/volumes"; type VolumeIconProps = { backend: BackendType; }; -const getIconAndColor = (backend: BackendType) => { +const getIconAndLabel = (backend: BackendType) => { switch (backend) { case "directory": return { icon: Folder, - color: "text-blue-600 dark:text-blue-400", label: "Directory", }; case "nfs": return { icon: Server, - color: "text-orange-600 dark:text-orange-400", label: "NFS", }; case "smb": return { - icon: Share2, - color: "text-purple-600 dark:text-purple-400", + icon: Server, label: "SMB", }; case "webdav": return { - icon: Cloud, - color: "text-green-600 dark:text-green-400", + icon: Server, label: "WebDAV", }; case "rclone": return { icon: Cloud, - color: "text-cyan-600 dark:text-cyan-400", label: "Rclone", }; + case "sftp": + return { + icon: Server, + label: "SFTP", + }; default: return { icon: Folder, - color: "text-gray-600 dark:text-gray-400", label: "Unknown", }; } }; export const VolumeIcon = ({ backend }: VolumeIconProps) => { - const { icon: Icon, label } = getIconAndColor(backend); + const { icon: Icon, label } = getIconAndLabel(backend); return ( diff --git a/app/client/modules/auth/components/reset-password-dialog.tsx b/app/client/modules/auth/components/reset-password-dialog.tsx new file mode 100644 index 0000000..d110b3b --- /dev/null +++ b/app/client/modules/auth/components/reset-password-dialog.tsx @@ -0,0 +1,40 @@ +import { toast } from "sonner"; +import { Button } from "~/client/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog"; +import { copyToClipboard } from "~/utils/clipboard"; + +const RESET_PASSWORD_COMMAND = "docker exec -it zerobyte bun run cli reset-password"; + +type ResetPasswordDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const ResetPasswordDialog = ({ open, onOpenChange }: ResetPasswordDialogProps) => { + const handleCopy = async () => { + await copyToClipboard(RESET_PASSWORD_COMMAND); + toast.success("Command copied to clipboard"); + }; + + return ( + + + + Reset your password + + To reset your password, run the following command on the server where Zerobyte is installed. + + +
+
{RESET_PASSWORD_COMMAND}
+

+ This command will start an interactive session where you can enter a new password for your account. +

+ +
+
+
+ ); +}; diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index 0299ecc..b62e0cc 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -7,13 +7,12 @@ import { useNavigate } from "react-router"; import { toast } from "sonner"; import { AuthLayout } from "~/client/components/auth-layout"; import { Button } from "~/client/components/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; import { authMiddleware } from "~/middleware/auth"; import type { Route } from "./+types/login"; import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen"; -import { copyToClipboard } from "~/utils/clipboard"; +import { ResetPasswordDialog } from "../components/reset-password-dialog"; export const clientMiddleware = [authMiddleware]; @@ -119,34 +118,3 @@ export default function LoginPage() { ); } - -const RESET_PASSWORD_COMMAND = "docker exec -it zerobyte bun run cli reset-password"; - -function ResetPasswordDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { - const handleCopy = async () => { - await copyToClipboard(RESET_PASSWORD_COMMAND); - toast.success("Command copied to clipboard"); - }; - - return ( - - - - Reset your password - - To reset your password, run the following command on the server where Zerobyte is installed. - - -
-
{RESET_PASSWORD_COMMAND}
-

- This command will start an interactive session where you can enter a new password for your account. -

- -
-
-
- ); -} diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 1d94ff1..63d3a8e 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 820e72b..0a1d4f3 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 4bdce96..7ed68ef 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/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 6753f13..d7b6b84 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -80,7 +80,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon isLoading, failureReason, } = useQuery({ - ...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.id.toString() } }), + ...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.shortId } }), }); const updateSchedule = useMutation({ diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx index 76f0c71..49b5ca5 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 01e8dc0..8b91366 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,529 +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. - - - )} - /> - ( - - 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 0000000..babd7c4 --- /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 0000000..a8d5fb3 --- /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 0000000..43a7daf --- /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 0000000..d969939 --- /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 + +