From e5435969beaeb8c0fa897eac3b2ef04d2dc6aa84 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:14:18 +0100 Subject: [PATCH 1/9] feat: remove individual snapshot (#26) --- .../api-client/@tanstack/react-query.gen.ts | 21 +- app/client/api-client/sdk.gen.ts | 14 +- app/client/api-client/types.gen.ts | 100 +++++++- app/client/components/snapshots-table.tsx | 224 ++++++++++++------ .../components/create-schedule-form.tsx | 4 +- .../components/snapshot-file-browser.tsx | 65 +++-- .../modules/backups/routes/backup-details.tsx | 68 ++++++ .../repositories/repositories.controller.ts | 9 + .../modules/repositories/repositories.dto.ts | 25 ++ .../repositories/repositories.service.ts | 13 + app/server/utils/restic.ts | 17 ++ 11 files changed, 452 insertions(+), 108 deletions(-) diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 910e0fd3..7cdc0d7e 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -460,6 +460,23 @@ export const listSnapshotsOptions = (options: Options) => que queryKey: listSnapshotsQueryKey(options) }); +/** + * Delete a specific snapshot from a repository + */ +export const deleteSnapshotMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteSnapshot({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getSnapshotDetailsQueryKey = (options: Options) => createQueryKey("getSnapshotDetails", options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 3e20b8c9..c71979b4 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetErrors, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -286,6 +286,16 @@ export const listSnapshots = (options: Opt }); }; +/** + * Delete a specific snapshot from a repository + */ +export const deleteSnapshot = (options: Options) => { + return (options.client ?? client).delete({ + url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', + ...options + }); +}; + /** * Get details of a specific snapshot */ @@ -422,7 +432,7 @@ export const stopBackup = (options: Option * Manually apply retention policy to clean up old snapshots */ export const runForget = (options: Options) => { - return (options.client ?? client).post({ + return (options.client ?? client).post({ url: '/api/v1/backups/{scheduleId}/forget', ...options }); diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index db35bf89..5a3de70b 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -711,30 +711,42 @@ export type ListRepositoriesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -757,30 +769,42 @@ export type CreateRepositoryData = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; name: string; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; @@ -865,30 +889,42 @@ export type GetRepositoryResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -929,6 +965,27 @@ export type ListSnapshotsResponses = { export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses]; +export type DeleteSnapshotData = { + body?: never; + path: { + name: string; + snapshotId: string; + }; + query?: never; + url: '/api/v1/repositories/{name}/snapshots/{snapshotId}'; +}; + +export type DeleteSnapshotResponses = { + /** + * Snapshot deleted successfully + */ + 200: { + message: string; + }; +}; + +export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapshotResponses]; + export type GetSnapshotDetailsData = { body?: never; path: { @@ -1079,30 +1136,42 @@ export type ListBackupSchedulesResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1280,30 +1349,42 @@ export type GetBackupScheduleResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1462,30 +1543,42 @@ export type GetBackupScheduleForVolumeResponses = { bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accessKeyId: string; backend: 's3'; bucket: string; endpoint: string; secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; } | { accountKey: string; accountName: string; backend: 'azure'; container: string; + customPassword?: string; endpointSuffix?: string; + isExistingRepository?: boolean; } | { backend: 'gcs'; bucket: string; credentialsJson: string; projectId: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'local'; name: string; + customPassword?: string; + isExistingRepository?: boolean; } | { backend: 'rclone'; path: string; remote: string; + customPassword?: string; + isExistingRepository?: boolean; }; createdAt: number; id: string; @@ -1611,13 +1704,6 @@ export type RunForgetData = { url: '/api/v1/backups/{scheduleId}/forget'; }; -export type RunForgetErrors = { - /** - * No retention policy configured for this schedule - */ - 400: unknown; -}; - export type RunForgetResponses = { /** * Retention policy applied successfully diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index e1852908..c03059d2 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -1,10 +1,26 @@ -import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react"; +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react"; import { 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 { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; +import { Button } from "~/client/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/client/components/ui/alert-dialog"; import { formatDuration } from "~/utils/utils"; import type { ListSnapshotsResponse } from "../api-client"; +import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/client/lib/errors"; type Snapshot = ListSnapshotsResponse[number]; @@ -15,81 +31,149 @@ type Props = { export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [snapshotToDelete, setSnapshotToDelete] = useState(null); + + const deleteSnapshot = useMutation({ + ...deleteSnapshotMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listSnapshots"] }); + setShowDeleteConfirm(false); + setSnapshotToDelete(null); + }, + }); + + const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => { + e.stopPropagation(); + setSnapshotToDelete(snapshotId); + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = () => { + if (snapshotToDelete) { + toast.promise( + deleteSnapshot.mutateAsync({ + path: { name: repositoryName, snapshotId: snapshotToDelete }, + }), + { + loading: "Deleting snapshot...", + success: "Snapshot deleted successfully", + error: (error) => parseError(error)?.message || "Failed to delete snapshot", + }, + ); + } + }; const handleRowClick = (snapshotId: string) => { navigate(`/repositories/${repositoryName}/${snapshotId}`); }; return ( -
- - - - Snapshot ID - Date & Time - Size - Duration - Paths - - - - {snapshots.map((snapshot) => ( - handleRowClick(snapshot.short_id)} - > - -
- - {snapshot.short_id} -
-
- -
- - {new Date(snapshot.time).toLocaleString()} -
-
- -
- - - - -
-
- -
- - {formatDuration(snapshot.duration / 1000)} -
-
- -
- - - - - {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} - - - -
- {snapshot.paths.map((path) => ( -
- {path} -
- ))} -
-
-
-
-
+ <> +
+
+ + + Snapshot ID + Date & Time + Size + Duration + Paths + Actions - ))} - -
-
+ + + {snapshots.map((snapshot) => ( + handleRowClick(snapshot.short_id)} + > + +
+ + {snapshot.short_id} +
+
+ +
+ + {new Date(snapshot.time).toLocaleString()} +
+
+ +
+ + + + +
+
+ +
+ + {formatDuration(snapshot.duration / 1000)} +
+
+ +
+ + + + + {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} + + + +
+ {snapshot.paths.map((path) => ( +
+ {path} +
+ ))} +
+
+
+
+
+ + + +
+ ))} +
+ + + + + + + Delete snapshot? + + This action cannot be undone. This will permanently delete the snapshot and all its data from the + repository. + + + + Cancel + + Delete snapshot + + + + + ); }; diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index d47d0326..ee4d1d87 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -254,8 +254,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Backup paths - Select which folders or files to include in the backup. If no paths are selected, the entire volume will be - backed up. + Select which folders or files to include in the backup. If no paths are selected, the entire volume will + be backed up. diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 3ccca736..6c40f3fa 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -26,10 +26,12 @@ interface Props { snapshot: Snapshot; repositoryName: string; volume?: Volume; + onDeleteSnapshot?: (snapshotId: string) => void; + isDeletingSnapshot?: boolean; } export const SnapshotFileBrowser = (props: Props) => { - const { snapshot, repositoryName, volume } = props; + const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props; const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; @@ -136,30 +138,43 @@ export const SnapshotFileBrowser = (props: Props) => { File Browser {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`} - {selectedPaths.size > 0 && ( - - - - - - - {isReadOnly && ( - -

Volume is mounted as read-only.

-

Please remount with read-only disabled to restore files.

-
- )} -
- )} +
+ {selectedPaths.size > 0 && ( + + + + + + + {isReadOnly && ( + +

Volume is mounted as read-only.

+

Please remount with read-only disabled to restore files.

+
+ )} +
+ )} + {onDeleteSnapshot && ( + + )} +
diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index a6311a3f..7281faf7 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { redirect, useNavigate } from "react-router"; import { toast } from "sonner"; import { Button } from "~/client/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/client/components/ui/alert-dialog"; import { getBackupScheduleOptions, runBackupNowMutation, @@ -10,6 +20,7 @@ import { listSnapshotsOptions, updateBackupScheduleMutation, stopBackupMutation, + deleteSnapshotMutation, } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; import { getCronExpression } from "~/utils/utils"; @@ -50,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const [isEditMode, setIsEditMode] = useState(false); const formId = useId(); const [selectedSnapshotId, setSelectedSnapshotId] = useState(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [snapshotToDelete, setSnapshotToDelete] = useState(null); const { data: schedule } = useQuery({ ...getBackupScheduleOptions({ path: { scheduleId: params.id } }), @@ -110,6 +123,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon }, }); + const deleteSnapshot = useMutation({ + ...deleteSnapshotMutation(), + onSuccess: () => { + setShowDeleteConfirm(false); + setSnapshotToDelete(null); + if (selectedSnapshotId === snapshotToDelete) { + setSelectedSnapshotId(undefined); + } + }, + }); + const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!schedule) return; @@ -150,6 +174,26 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon }); }; + const handleDeleteSnapshot = (snapshotId: string) => { + setSnapshotToDelete(snapshotId); + setShowDeleteConfirm(true); + }; + + const handleConfirmDelete = () => { + if (snapshotToDelete) { + toast.promise( + deleteSnapshot.mutateAsync({ + path: { name: schedule.repository.name, snapshotId: snapshotToDelete }, + }), + { + loading: "Deleting snapshot...", + success: "Snapshot deleted successfully", + error: (error) => parseError(error)?.message || "Failed to delete snapshot", + }, + ); + } + }; + if (isEditMode) { return (
@@ -191,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon snapshot={selectedSnapshot} repositoryName={schedule.repository.name} volume={schedule.volume} + onDeleteSnapshot={handleDeleteSnapshot} + isDeletingSnapshot={deleteSnapshot.isPending} /> )} + + + + + Delete snapshot? + + This action cannot be undone. This will permanently delete the snapshot and all its data from the + repository. + + + + Cancel + + Delete snapshot + + + +
); } diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index b6a67a4e..2cda9983 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -4,6 +4,7 @@ import { createRepositoryBody, createRepositoryDto, deleteRepositoryDto, + deleteSnapshotDto, doctorRepositoryDto, getRepositoryDto, getSnapshotDetailsDto, @@ -16,6 +17,7 @@ import { restoreSnapshotBody, restoreSnapshotDto, type DeleteRepositoryDto, + type DeleteSnapshotDto, type DoctorRepositoryDto, type GetRepositoryDto, type GetSnapshotDetailsDto, @@ -142,4 +144,11 @@ export const repositoriesController = new Hono() const result = await repositoriesService.doctorRepository(name); return c.json(result, 200); + }) + .delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => { + const { name, snapshotId } = c.req.param(); + + await repositoriesService.deleteSnapshot(name, snapshotId); + + return c.json({ message: "Snapshot deleted" }, 200); }); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index da4d35ff..88f52629 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -326,3 +326,28 @@ export const listRcloneRemotesDto = describeRoute({ }, }, }); + +/** + * Delete a snapshot + */ +export const deleteSnapshotResponse = type({ + message: "string", +}); + +export type DeleteSnapshotDto = typeof deleteSnapshotResponse.infer; + +export const deleteSnapshotDto = describeRoute({ + description: "Delete a specific snapshot from a repository", + tags: ["Repositories"], + operationId: "deleteSnapshot", + responses: { + 200: { + description: "Snapshot deleted successfully", + content: { + "application/json": { + schema: resolver(deleteSnapshotResponse), + }, + }, + }, + }, +}); diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 80bb8e87..b95380b4 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -327,6 +327,18 @@ const doctorRepository = async (name: string) => { }; }; +const deleteSnapshot = async (name: string, snapshotId: string) => { + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + await restic.deleteSnapshot(repository.config, snapshotId); +}; + export const repositoriesService = { listRepositories, createRepository, @@ -338,4 +350,5 @@ export const repositoriesService = { getSnapshotDetails, checkHealth, doctorRepository, + deleteSnapshot, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index a85757a1..eaadec32 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -441,6 +441,22 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: return { success: true }; }; +const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config); + + const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"]; + + const res = await $`restic ${args}`.env(env).nothrow(); + + if (res.exitCode !== 0) { + logger.error(`Restic snapshot deletion failed: ${res.stderr}`); + throw new Error(`Failed to delete snapshot: ${res.stderr}`); + } + + return { success: true }; +}; + const lsNodeSchema = type({ name: "string", type: "string", @@ -601,6 +617,7 @@ export const restic = { restore, snapshots, forget, + deleteSnapshot, unlock, ls, check, From 98924ea59dac82bed605dc05a42cf01bd59c3a86 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 16 Nov 2025 11:51:00 +0100 Subject: [PATCH 2/9] fix: timezone parsing cron --- README.md | 5 +++++ app/server/modules/backups/backups.service.ts | 2 +- docker-compose.yml | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e6d7e9e9..c9f5a3e6 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ services: devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount ``` @@ -78,6 +79,7 @@ services: devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount + - /path/to/your/directory:/mydata ``` @@ -143,6 +145,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount + - ~/.config/rclone:/root/.config/rclone ``` @@ -197,6 +200,7 @@ services: devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - - /var/lib/ironmount:/var/lib/ironmount + - /var/lib/ironmount:/var/lib/ironmount:rshared ``` @@ -227,6 +231,7 @@ services: devices: - /dev/fuse:/dev/fuse volumes: + - /etc/localtime:/etc/localtime:ro - - /var/lib/ironmount:/var/lib/ironmount + - /var/lib/ironmount:/var/lib/ironmount:rshared + - /run/docker/plugins:/run/docker/plugins diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 2632277b..9e984593 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -17,7 +17,7 @@ const calculateNextRun = (cronExpression: string): number => { try { const interval = CronExpressionParser.parse(cronExpression, { currentDate: new Date(), - tz: "UTC", + tz: Intl.DateTimeFormat().resolvedOptions().timeZone, }); return interval.next().getTime(); diff --git a/docker-compose.yml b/docker-compose.yml index 051ac1f4..20371803 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: ports: - "4096:4096" volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount - ./app:/app/app @@ -37,6 +38,7 @@ services: ports: - "4096:4096" volumes: + - /etc/localtime:/etc/localtime:ro - /var/lib/ironmount:/var/lib/ironmount:rshared - /run/docker/plugins:/run/docker/plugins - /var/run/docker.sock:/var/run/docker.sock From 67b1accbd02b2f4017fa42d259c7aada3537c07f Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 16 Nov 2025 12:53:13 +0100 Subject: [PATCH 3/9] docs: add warning for location of /var/lib/ironmount --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c9f5a3e6..2ea629ac 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed ```yaml services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped cap_add: @@ -50,6 +50,9 @@ services: - /var/lib/ironmount:/var/lib/ironmount ``` +> [!WARNING] +> Do not try to change the location of the bind mount `/var/lib/ironmount` on your host or store it on a network share. You will likely face permission issues and strong performance degradation. + Then, run the following command to start Ironmount: ```bash @@ -69,7 +72,7 @@ If you want to track a local directory on the same server where Ironmount is run ```diff services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped cap_add: @@ -135,7 +138,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro ```diff services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped cap_add: @@ -192,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir ```diff services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped ports: @@ -221,7 +224,7 @@ In order to enable this feature, you need to run Ironmount with several items sh ```diff services: ironmount: - image: ghcr.io/nicotsx/ironmount:v0.9 + image: ghcr.io/nicotsx/ironmount:v0.10 container_name: ironmount restart: unless-stopped cap_add: From 0d36484c04f8fa20e46e070bdba4379586f0fe9d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Nov 2025 16:49:35 +0100 Subject: [PATCH 4/9] Add "Ironmount" prefix to page titles and display version in sidebar (#28) * Initial plan * Initial exploration - understanding the codebase Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com> * Add "Ironmount - " prefix to all route titles and version in sidebar Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com> --- app/client/components/app-sidebar.tsx | 11 +++++++++++ app/client/lib/version.ts | 1 + .../modules/auth/routes/download-recovery-key.tsx | 2 +- app/client/modules/auth/routes/login.tsx | 2 +- app/client/modules/auth/routes/onboarding.tsx | 2 +- app/client/modules/backups/routes/backup-details.tsx | 2 +- app/client/modules/backups/routes/backups.tsx | 2 +- app/client/modules/backups/routes/create-backup.tsx | 2 +- .../repositories/routes/create-repository.tsx | 2 +- .../modules/repositories/routes/repositories.tsx | 2 +- .../repositories/routes/repository-details.tsx | 2 +- .../modules/repositories/routes/snapshot-details.tsx | 2 +- app/client/modules/settings/routes/settings.tsx | 2 +- app/client/modules/volumes/routes/create-volume.tsx | 2 +- app/client/modules/volumes/routes/volume-details.tsx | 2 +- app/client/modules/volumes/routes/volumes.tsx | 2 +- bun.lock | 1 + vite.config.ts | 12 ++++++++++++ 18 files changed, 39 insertions(+), 14 deletions(-) create mode 100644 app/client/lib/version.ts diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index 9995ee1b..a162f62c 100644 --- a/app/client/components/app-sidebar.tsx +++ b/app/client/components/app-sidebar.tsx @@ -3,6 +3,7 @@ import { Link, NavLink } from "react-router"; import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, @@ -13,6 +14,7 @@ import { } from "~/client/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip"; import { cn } from "~/client/lib/utils"; +import { APP_VERSION } from "~/client/lib/version"; const items = [ { @@ -85,6 +87,15 @@ export function AppSidebar() { + +
+ Version {APP_VERSION} +
+
); } diff --git a/app/client/lib/version.ts b/app/client/lib/version.ts new file mode 100644 index 00000000..6bc6a20c --- /dev/null +++ b/app/client/lib/version.ts @@ -0,0 +1 @@ +export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev"; diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index 488de694..7f8fee50 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware]; export function meta(_: Route.MetaArgs) { return [ - { title: "Download Recovery Key" }, + { title: "Ironmount - Download Recovery Key" }, { name: "description", content: "Download your backup recovery key to ensure you can restore your data.", diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index 29d73084..11400234 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware]; export function meta(_: Route.MetaArgs) { return [ - { title: "Login" }, + { title: "Ironmount - Login" }, { name: "description", content: "Sign in to your Ironmount account.", diff --git a/app/client/modules/auth/routes/onboarding.tsx b/app/client/modules/auth/routes/onboarding.tsx index e14296c8..bb72df15 100644 --- a/app/client/modules/auth/routes/onboarding.tsx +++ b/app/client/modules/auth/routes/onboarding.tsx @@ -24,7 +24,7 @@ export const clientMiddleware = [authMiddleware]; export function meta(_: Route.MetaArgs) { return [ - { title: "Onboarding" }, + { title: "Ironmount - Onboarding" }, { name: "description", content: "Welcome to Ironmount. Create your admin account to get started.", diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 7281faf7..2a0b4bad 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -40,7 +40,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Backup Job Details" }, + { title: "Ironmount - Backup Job Details" }, { name: "description", content: "View and manage backup job configuration, schedule, and snapshots.", diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx index 3908fb3e..3b4b21df 100644 --- a/app/client/modules/backups/routes/backups.tsx +++ b/app/client/modules/backups/routes/backups.tsx @@ -15,7 +15,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Backup Jobs" }, + { title: "Ironmount - Backup Jobs" }, { name: "description", content: "Automate volume backups with scheduled jobs and retention policies.", diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index fee33cee..909bd866 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -24,7 +24,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Create Backup Job" }, + { title: "Ironmount - Create Backup Job" }, { name: "description", content: "Create a new automated backup job for your volumes.", diff --git a/app/client/modules/repositories/routes/create-repository.tsx b/app/client/modules/repositories/routes/create-repository.tsx index 5c30eda6..a84c54be 100644 --- a/app/client/modules/repositories/routes/create-repository.tsx +++ b/app/client/modules/repositories/routes/create-repository.tsx @@ -17,7 +17,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Create Repository" }, + { title: "Ironmount - Create Repository" }, { name: "description", content: "Create a new backup repository with encryption and compression.", diff --git a/app/client/modules/repositories/routes/repositories.tsx b/app/client/modules/repositories/routes/repositories.tsx index ecb0114e..27bfa6ca 100644 --- a/app/client/modules/repositories/routes/repositories.tsx +++ b/app/client/modules/repositories/routes/repositories.tsx @@ -20,7 +20,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Repositories" }, + { title: "Ironmount - Repositories" }, { name: "description", content: "Manage your backup repositories with encryption and compression.", diff --git a/app/client/modules/repositories/routes/repository-details.tsx b/app/client/modules/repositories/routes/repository-details.tsx index c18d517d..8a091dcf 100644 --- a/app/client/modules/repositories/routes/repository-details.tsx +++ b/app/client/modules/repositories/routes/repository-details.tsx @@ -36,7 +36,7 @@ export const handle = { export function meta({ params }: Route.MetaArgs) { return [ - { title: params.name }, + { title: `Ironmount - ${params.name}` }, { name: "description", content: "View repository configuration, status, and snapshots.", diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index 50e49b70..ccfd34ff 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -17,7 +17,7 @@ export const handle = { export function meta({ params }: Route.MetaArgs) { return [ - { title: `Snapshot ${params.snapshotId}` }, + { title: `Ironmount - Snapshot ${params.snapshotId}` }, { name: "description", content: "Browse and restore files from a backup snapshot.", diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 6b6e2b77..e5728cf1 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -30,7 +30,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Settings" }, + { title: "Ironmount - Settings" }, { name: "description", content: "Manage your account settings and preferences.", diff --git a/app/client/modules/volumes/routes/create-volume.tsx b/app/client/modules/volumes/routes/create-volume.tsx index 9930807f..cf9ad1f4 100644 --- a/app/client/modules/volumes/routes/create-volume.tsx +++ b/app/client/modules/volumes/routes/create-volume.tsx @@ -17,7 +17,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Create Volume" }, + { title: "Ironmount - Create Volume" }, { name: "description", content: "Create a new storage volume with automatic mounting and health checks.", diff --git a/app/client/modules/volumes/routes/volume-details.tsx b/app/client/modules/volumes/routes/volume-details.tsx index 3059e0cf..af645a6e 100644 --- a/app/client/modules/volumes/routes/volume-details.tsx +++ b/app/client/modules/volumes/routes/volume-details.tsx @@ -37,7 +37,7 @@ export const handle = { export function meta({ params }: Route.MetaArgs) { return [ - { title: params.name }, + { title: `Ironmount - ${params.name}` }, { name: "description", content: "View and manage volume details, configuration, and files.", diff --git a/app/client/modules/volumes/routes/volumes.tsx b/app/client/modules/volumes/routes/volumes.tsx index eb9969ff..6240a6e8 100644 --- a/app/client/modules/volumes/routes/volumes.tsx +++ b/app/client/modules/volumes/routes/volumes.tsx @@ -20,7 +20,7 @@ export const handle = { export function meta(_: Route.MetaArgs) { return [ - { title: "Volumes" }, + { title: "Ironmount - Volumes" }, { name: "description", content: "Create, manage, monitor, and automate your Docker volumes with ease.", diff --git a/bun.lock b/bun.lock index 1f24ce94..c8af67a9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@ironmount/client", diff --git a/vite.config.ts b/vite.config.ts index e55ab311..9782fec2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,9 +3,21 @@ import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { reactRouterHonoServer } from "react-router-hono-server/dev"; +import { execSync } from "node:child_process"; + +const getVersion = () => { + try { + return execSync("git describe --tags --always").toString().trim(); + } catch { + return "dev"; + } +}; export default defineConfig({ plugins: [reactRouterHonoServer({ runtime: "bun" }), reactRouter(), tailwindcss(), tsconfigPaths()], + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify(getVersion()), + }, build: { outDir: "dist", sourcemap: true, From a3e027694a2d464fb435eff7ff518103bfa9f076 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 16 Nov 2025 16:53:29 +0100 Subject: [PATCH 5/9] ci: fix version injection to be a docker build arg --- .github/workflows/release.yml | 2 ++ Dockerfile | 3 +++ vite.config.ts | 7 +------ 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90963640..de29e554 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APP_VERSION=${{ needs.determine-release-type.outputs.tagname }} publish-release: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 2803f684..849d93ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,9 @@ CMD ["bun", "run", "dev"] # ------------------------------ FROM oven/bun:${BUN_VERSION} AS builder +ARG APP_VERSION=dev +ENV VITE_APP_VERSION=${APP_VERSION} + WORKDIR /app COPY ./package.json ./bun.lock ./ diff --git a/vite.config.ts b/vite.config.ts index 9782fec2..f456fcca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,14 +3,9 @@ import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { reactRouterHonoServer } from "react-router-hono-server/dev"; -import { execSync } from "node:child_process"; const getVersion = () => { - try { - return execSync("git describe --tags --always").toString().trim(); - } catch { - return "dev"; - } + return process.env.VITE_APP_VERSION || "dev"; }; export default defineConfig({ From ed2a625fa72beb2f189312cf8683d4367c311ccb Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 16 Nov 2025 17:11:30 +0100 Subject: [PATCH 6/9] ci: fix app version build arg --- Dockerfile | 4 +++- vite.config.ts | 7 ------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 849d93ff..7862ca03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,7 +60,6 @@ CMD ["bun", "run", "dev"] FROM oven/bun:${BUN_VERSION} AS builder ARG APP_VERSION=dev -ENV VITE_APP_VERSION=${APP_VERSION} WORKDIR /app @@ -69,6 +68,9 @@ 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 diff --git a/vite.config.ts b/vite.config.ts index f456fcca..e55ab311 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,15 +4,8 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { reactRouterHonoServer } from "react-router-hono-server/dev"; -const getVersion = () => { - return process.env.VITE_APP_VERSION || "dev"; -}; - export default defineConfig({ plugins: [reactRouterHonoServer({ runtime: "bun" }), reactRouter(), tailwindcss(), tsconfigPaths()], - define: { - "import.meta.env.VITE_APP_VERSION": JSON.stringify(getVersion()), - }, build: { outDir: "dist", sourcemap: true, From 94423bd0a546ffc23254a82517dde001e72a1fd0 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 16 Nov 2025 17:20:46 +0100 Subject: [PATCH 7/9] chore: remove unnecessary deps --- app/client/components/app-sidebar.tsx | 2 +- bun.lock | 9 +-------- package.json | 4 +--- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index a162f62c..19ef8549 100644 --- a/app/client/components/app-sidebar.tsx +++ b/app/client/components/app-sidebar.tsx @@ -93,7 +93,7 @@ export function AppSidebar() { "opacity-0 w-0 overflow-hidden": state === "collapsed", })} > - Version {APP_VERSION} + {APP_VERSION} diff --git a/bun.lock b/bun.lock index c8af67a9..2bddbf87 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,9 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@ironmount/client", "dependencies": { - "@hono/node-server": "^1.19.6", "@hono/standard-validator": "^0.1.5", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -24,8 +22,6 @@ "@react-router/serve": "^7.9.3", "@scalar/hono-api-reference": "^0.9.24", "@tanstack/react-query": "^5.90.2", - "@tanstack/react-query-devtools": "^5.90.2", - "@tanstack/react-table": "^8.21.3", "arktype": "^2.1.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -61,6 +57,7 @@ "@hey-api/openapi-ts": "^0.87.4", "@react-router/dev": "^7.9.3", "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-query-devtools": "^5.90.2", "@types/bun": "^1.3.2", "@types/dockerode": "^3.3.45", "@types/node": "^24.6.2", @@ -483,10 +480,6 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="], - "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], - - "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], - "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], diff --git a/package.json b/package.json index 79eec326..bb2af0d6 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "studio": "drizzle-kit studio" }, "dependencies": { - "@hono/node-server": "^1.19.6", "@hono/standard-validator": "^0.1.5", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -36,8 +35,6 @@ "@react-router/serve": "^7.9.3", "@scalar/hono-api-reference": "^0.9.24", "@tanstack/react-query": "^5.90.2", - "@tanstack/react-query-devtools": "^5.90.2", - "@tanstack/react-table": "^8.21.3", "arktype": "^2.1.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -73,6 +70,7 @@ "@hey-api/openapi-ts": "^0.87.4", "@react-router/dev": "^7.9.3", "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-query-devtools": "^5.90.2", "@types/bun": "^1.3.2", "@types/dockerode": "^3.3.45", "@types/node": "^24.6.2", From df6b70c96f4f17c90e5ae949533acf6af9bfb154 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 16 Nov 2025 17:27:49 +0100 Subject: [PATCH 8/9] fix(create-volume): all port fields as number --- app/client/components/create-volume-form.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/client/components/create-volume-form.tsx b/app/client/components/create-volume-form.tsx index 4f2c588f..6f2f3019 100644 --- a/app/client/components/create-volume-form.tsx +++ b/app/client/components/create-volume-form.tsx @@ -207,7 +207,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for Port - + field.onChange(parseInt(e.target.value, 10) || undefined)} + /> NFS server port (default: 2049). @@ -332,7 +337,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for Port - + field.onChange(parseInt(e.target.value, 10) || undefined)} + /> WebDAV server port (default: 80 for HTTP, 443 for HTTPS). From 4d48d7be58b78a6b435c351a5d16cb2683e386ca Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 16 Nov 2025 18:24:09 +0100 Subject: [PATCH 9/9] feat: add support for REST server --- .../components/create-repository-form.tsx | 63 +++++++++++++++++++ app/client/components/repository-icon.tsx | 4 +- app/schemas/restic.ts | 12 +++- .../repositories/repositories.service.ts | 8 +++ app/server/utils/restic.ts | 13 ++++ 5 files changed, 98 insertions(+), 2 deletions(-) diff --git a/app/client/components/create-repository-form.tsx b/app/client/components/create-repository-form.tsx index f9e2ab6a..724eda8f 100644 --- a/app/client/components/create-repository-form.tsx +++ b/app/client/components/create-repository-form.tsx @@ -41,6 +41,7 @@ const defaultValuesForType = { gcs: { backend: "gcs" as const, compressionMode: "auto" as const }, azure: { backend: "azure" as const, compressionMode: "auto" as const }, rclone: { backend: "rclone" as const, compressionMode: "auto" as const }, + rest: { backend: "rest" as const, compressionMode: "auto" as const }, }; export const CreateRepositoryForm = ({ @@ -126,6 +127,7 @@ export const CreateRepositoryForm = ({ Cloudflare R2 Google Cloud Storage Azure Blob Storage + REST Server @@ -546,6 +548,67 @@ export const CreateRepositoryForm = ({ ))} + {watchedBackend === "rest" && ( + <> + ( + + REST Server URL + + + + URL of the REST server. + + + )} + /> + ( + + Repository Path (Optional) + + + + Path to the repository on the REST server (leave empty for root). + + + )} + /> + ( + + Username (Optional) + + + + Username for REST server authentication. + + + )} + /> + ( + + Password (Optional) + + + + Password for REST server authentication. + + + )} + /> + + )} + {mode === "update" && (