diff --git a/Dockerfile b/Dockerfile index c3448f9a..c2134e67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,14 @@ ARG BUN_VERSION="1.3.5" FROM oven/bun:${BUN_VERSION}-alpine AS base +ARG RESTIC_VERSION="0.18.1" +ARG RCLONE_VERSION="1.72.1" +ARG SHOUTRRR_VERSION="0.13.1" + +ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ + VITE_RCLONE_VERSION=${RCLONE_VERSION} \ + VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION} + RUN apk upgrade --no-cache && \ apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini @@ -16,22 +24,19 @@ FROM base AS deps WORKDIR /deps ARG TARGETARCH -ARG RESTIC_VERSION="0.18.1" -ARG RCLONE_VERSION="1.72.1" -ARG SHOUTRRR_VERSION="0.13.1" ENV TARGETARCH=${TARGETARCH} RUN apk add --no-cache curl bzip2 unzip tar RUN echo "Building for ${TARGETARCH}" RUN if [ "${TARGETARCH}" = "arm64" ]; then \ - curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \ - curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v$RCLONE_VERSION/rclone-v$RCLONE_VERSION-linux-arm64.zip"; \ + curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \ + curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \ unzip rclone.zip; \ - curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \ + curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \ elif [ "${TARGETARCH}" = "amd64" ]; then \ - curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \ - curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v$RCLONE_VERSION/rclone-v$RCLONE_VERSION-linux-amd64.zip"; \ + curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \ + curl -L -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip"; \ unzip rclone.zip; \ curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \ fi @@ -45,6 +50,9 @@ RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr # ------------------------------ FROM base AS development +ARG APP_VERSION=dev +ENV APP_VERSION=${APP_VERSION} +ENV VITE_APP_VERSION=${APP_VERSION} ENV NODE_ENV="development" WORKDIR /app @@ -66,9 +74,10 @@ CMD ["bun", "run", "dev"] # ------------------------------ # PRODUCTION # ------------------------------ -FROM oven/bun:${BUN_VERSION}-alpine AS builder +FROM base AS builder ARG APP_VERSION=dev +ENV VITE_APP_VERSION=${APP_VERSION} WORKDIR /app @@ -77,13 +86,12 @@ RUN bun install --frozen-lockfile COPY . . -RUN touch .env -RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env - 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 4ceb8163..c48914ce 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: @@ -50,7 +50,7 @@ services: devices: - /dev/fuse:/dev/fuse environment: - - TZ=Europe/Paris # Set your timezone here + - TZ=Europe/Paris # Set your timezone here volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte @@ -70,6 +70,30 @@ docker compose up -d Once the container is running, you can access the web interface at `http://:4096`. +## Configuration + +Zerobyte can be customized using environment variables. Below are the available options: + +### Environment Variables + +| Variable | Description | Default | +| :-------------------- | :------------------------------------------------------------------------------------------------------------ | :--------- | +| `PORT` | The port the web interface and API will listen on. | `4096` | +| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` | +| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` | +| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` | +| `TZ` | Timezone for the container (e.g., `Europe/Paris`). **Crucial for accurate backup scheduling.** | `UTC` | + +### Secret References + +For enhanced security, Zerobyte supports dynamic secret resolution for sensitive fields (like passwords, access keys, etc.) in volume and repository configurations. Instead of storing the encrypted secret in the database, you can use one of the following prefixes: + +- `env://VAR_NAME`: Reads the secret from the environment variable `VAR_NAME`. +- `file://SECRET_NAME`: Reads the secret from `/run/secrets/SECRET_NAME` (standard Docker Secrets path). + +**Example:** +When configuring an S3 repository, you can set the Secret Access Key to `env://S3_SECRET_KEY` and then provide that variable in your `docker-compose.yml`. + ### Simplified setup (No remote mounts) If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`: @@ -77,13 +101,13 @@ 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: - "4096:4096" environment: - - TZ=Europe/Paris # Set your timezone here + - TZ=Europe/Paris # Set your timezone here volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte @@ -91,6 +115,7 @@ services: ``` **Trade-offs:** + - ✅ Improved security by reducing container capabilities - ✅ Support for local directories - ✅ Keep support all repository types (local, S3, GCS, Azure, rclone) @@ -113,7 +138,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 +211,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 f0e1b7d8..108068f5 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, 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'; +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 @@ -961,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 a0e13c5b..6760a83b 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, 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'; +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 & { /** @@ -409,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 44b2b3c5..5c8f13e5 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,34 +808,44 @@ 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'; @@ -853,7 +865,9 @@ export type ListRepositoriesResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -879,7 +893,9 @@ export type CreateRepositoryData = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -887,34 +903,44 @@ 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'; @@ -934,7 +960,9 @@ export type CreateRepositoryData = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1022,7 +1050,9 @@ export type GetRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1030,34 +1060,44 @@ 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'; @@ -1077,7 +1117,9 @@ export type GetRepositoryResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1130,7 +1172,9 @@ export type UpdateRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1138,34 +1182,44 @@ 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'; @@ -1185,7 +1239,9 @@ export type UpdateRepositoryResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1455,7 +1511,9 @@ export type ListBackupSchedulesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1463,34 +1521,44 @@ 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'; @@ -1510,7 +1578,9 @@ export type ListBackupSchedulesResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1719,7 +1789,9 @@ export type GetBackupScheduleResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1727,34 +1799,44 @@ 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'; @@ -1774,7 +1856,9 @@ export type GetBackupScheduleResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -1964,7 +2048,9 @@ export type GetBackupScheduleForVolumeResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -1972,34 +2058,44 @@ 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'; @@ -2019,7 +2115,9 @@ export type GetBackupScheduleForVolumeResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -2396,7 +2494,9 @@ export type GetScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -2404,34 +2504,44 @@ 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'; @@ -2451,7 +2561,9 @@ export type GetScheduleMirrorsResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -2504,7 +2616,9 @@ export type UpdateScheduleMirrorsResponses = { bucket: string; endpoint: string; secretAccessKey: string; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; } | { accessKeyId: string; @@ -2512,34 +2626,44 @@ 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'; @@ -2559,7 +2683,9 @@ export type UpdateScheduleMirrorsResponses = { user: string; port?: number; skipHostKeyCheck?: boolean; + cacert?: string; customPassword?: string; + insecureTls?: boolean; isExistingRepository?: boolean; knownHosts?: string; }; @@ -3168,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/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index 7796b467..2ed0cd5e 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, @@ -13,8 +14,11 @@ import { useSidebar, } from "~/client/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "~/client/components/ui/hover-card"; import { cn } from "~/client/lib/utils"; -import { APP_VERSION } from "~/client/lib/version"; +import { APP_VERSION, RCLONE_VERSION, RESTIC_VERSION, SHOUTRRR_VERSION } from "~/client/lib/version"; +import { useUpdates } from "~/client/hooks/use-updates"; +import { ReleaseNotesDialog } from "./release-notes-dialog"; const items = [ { @@ -46,6 +50,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 +105,44 @@ export function AppSidebar() { - - {displayVersion} - +
+ + + + {displayVersion} + + + +
+
+ Restic: + {RESTIC_VERSION} + Rclone: + {RCLONE_VERSION} + Shoutrrr: + {SHOUTRRR_VERSION} +
+
+
+
+ {hasUpdate && state !== "collapsed" && ( + + )} +
+
); 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/ui/collapsible.tsx b/app/client/components/ui/collapsible.tsx index 5e13272d..7c52206f 100644 --- a/app/client/components/ui/collapsible.tsx +++ b/app/client/components/ui/collapsible.tsx @@ -56,15 +56,15 @@ const CollapsibleTrigger = React.forwardRefsvg]:rotate-180", + "flex gap-2 items-center justify-between py-2 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", className, )} data-state={open ? "open" : "closed"} onClick={() => setOpen(!open)} {...props} > - {children} + {children} ); }, diff --git a/app/client/components/ui/hover-card.tsx b/app/client/components/ui/hover-card.tsx new file mode 100644 index 00000000..e420e624 --- /dev/null +++ b/app/client/components/ui/hover-card.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "~/client/lib/utils" + +function HoverCard({ + ...props +}: React.ComponentProps) { + return +} + +function HoverCardTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function HoverCardContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } 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/lib/version.ts b/app/client/lib/version.ts index 6bc6a20c..c97647a6 100644 --- a/app/client/lib/version.ts +++ b/app/client/lib/version.ts @@ -1 +1,4 @@ export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev"; +export const RESTIC_VERSION = import.meta.env.VITE_RESTIC_VERSION || "unknown"; +export const RCLONE_VERSION = import.meta.env.VITE_RCLONE_VERSION || "unknown"; +export const SHOUTRRR_VERSION = import.meta.env.VITE_SHOUTRRR_VERSION || "unknown"; diff --git a/app/client/modules/notifications/components/notification-forms/custom-form.tsx b/app/client/modules/notifications/components/notification-forms/custom-form.tsx index babd7c40..165ba644 100644 --- a/app/client/modules/notifications/components/notification-forms/custom-form.tsx +++ b/app/client/modules/notifications/components/notification-forms/custom-form.tsx @@ -24,7 +24,7 @@ export const CustomForm = ({ form }: Props) => { Direct Shoutrrr URL for power users. See  { Topic - + The ntfy topic name to publish to. diff --git a/app/client/modules/notifications/routes/notification-details.tsx b/app/client/modules/notifications/routes/notification-details.tsx index 1a29229c..ba6ea340 100644 --- a/app/client/modules/notifications/routes/notification-details.tsx +++ b/app/client/modules/notifications/routes/notification-details.tsx @@ -200,10 +200,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP - - - Cancel - + Cancel Delete diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index becbf94d..9d590b55 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -270,7 +270,7 @@ export const CreateRepositoryForm = ({ {watchedBackend === "rest" && } {watchedBackend === "sftp" && } - + {watchedBackend && watchedBackend !== "local" && } {mode === "update" && (