Compare commits

...

12 Commits

Author SHA1 Message Date
Nicolas Meienberger
685f0223f5 chore test 2025-11-18 18:53:06 +01:00
Nicolas Meienberger
70e4c782ff fix: undefined path in local repo 2025-11-17 21:09:46 +01:00
Nicolas Meienberger
c726c6fc72 feat: custom local repository path 2025-11-17 18:17:51 +01:00
Nicolas Meienberger
4d48d7be58 feat: add support for REST server 2025-11-16 18:24:09 +01:00
Nicolas Meienberger
df6b70c96f fix(create-volume): all port fields as number 2025-11-16 17:27:49 +01:00
Nicolas Meienberger
94423bd0a5 chore: remove unnecessary deps 2025-11-16 17:20:46 +01:00
Nicolas Meienberger
ed2a625fa7 ci: fix app version build arg 2025-11-16 17:11:30 +01:00
Nicolas Meienberger
a3e027694a ci: fix version injection to be a docker build arg 2025-11-16 16:53:29 +01:00
Copilot
0d36484c04 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>
2025-11-16 16:49:35 +01:00
Nicolas Meienberger
67b1accbd0 docs: add warning for location of /var/lib/ironmount 2025-11-16 12:53:13 +01:00
Nicolas Meienberger
98924ea59d fix: timezone parsing cron 2025-11-16 11:51:00 +01:00
Nico
e5435969be feat: remove individual snapshot (#26) 2025-11-16 11:14:18 +01:00
37 changed files with 774 additions and 149 deletions

View File

@@ -74,6 +74,8 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
publish-release: publish-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -59,6 +59,8 @@ CMD ["bun", "run", "dev"]
# ------------------------------ # ------------------------------
FROM oven/bun:${BUN_VERSION} AS builder FROM oven/bun:${BUN_VERSION} AS builder
ARG APP_VERSION=dev
WORKDIR /app WORKDIR /app
COPY ./package.json ./bun.lock ./ COPY ./package.json ./bun.lock ./
@@ -66,6 +68,9 @@ RUN bun install --frozen-lockfile
COPY . . COPY . .
RUN touch .env
RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env
RUN bun run build RUN bun run build
FROM base AS production FROM base AS production

View File

@@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml ```yaml
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9 image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -46,9 +46,13 @@ services:
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
volumes: volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount - /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: Then, run the following command to start Ironmount:
```bash ```bash
@@ -68,7 +72,7 @@ If you want to track a local directory on the same server where Ironmount is run
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9 image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -78,6 +82,7 @@ services:
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
volumes: volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount - /var/lib/ironmount:/var/lib/ironmount
+ - /path/to/your/directory:/mydata + - /path/to/your/directory:/mydata
``` ```
@@ -133,7 +138,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9 image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -143,6 +148,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
volumes: volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount - /var/lib/ironmount:/var/lib/ironmount
+ - ~/.config/rclone:/root/.config/rclone + - ~/.config/rclone:/root/.config/rclone
``` ```
@@ -189,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9 image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -197,6 +203,7 @@ services:
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
volumes: volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/ironmount:/var/lib/ironmount - - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared + - /var/lib/ironmount:/var/lib/ironmount:rshared
``` ```
@@ -217,7 +224,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
```diff ```diff
services: services:
ironmount: ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9 image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount container_name: ironmount
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -227,6 +234,7 @@ services:
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
volumes: volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/ironmount:/var/lib/ironmount - - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared + - /var/lib/ironmount:/var/lib/ironmount:rshared
+ - /run/docker/plugins:/run/docker/plugins + - /run/docker/plugins:/run/docker/plugins
@@ -279,3 +287,5 @@ Ironmount includes [Restic](https://github.com/restic/restic) for backup functio
- **License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt) - **License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
For a complete list of third-party software licenses and attributions, please refer to the [NOTICES.md](NOTICES.md) file. For a complete list of third-party software licenses and attributions, please refer to the [NOTICES.md](NOTICES.md) file.
Test5

View File

@@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; 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 { 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, 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 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 * Register a new user
@@ -460,6 +460,23 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => que
queryKey: listSnapshotsQueryKey(options) queryKey: listSnapshotsQueryKey(options)
}); });
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshotMutation = (options?: Partial<Options<DeleteSnapshotData>>): UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> => {
const mutationOptions: UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSnapshot({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options); export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options);
/** /**

View File

@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; 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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
@@ -286,6 +286,16 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(options: Opt
}); });
}; };
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
...options
});
};
/** /**
* Get details of a specific snapshot * Get details of a specific snapshot
*/ */
@@ -422,7 +432,7 @@ export const stopBackup = <ThrowOnError extends boolean = false>(options: Option
* Manually apply retention policy to clean up old snapshots * Manually apply retention policy to clean up old snapshots
*/ */
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => { export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => {
return (options.client ?? client).post<RunForgetResponses, RunForgetErrors, ThrowOnError>({ return (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/forget', url: '/api/v1/backups/{scheduleId}/forget',
...options ...options
}); });

View File

@@ -711,30 +711,51 @@ export type ListRepositoriesResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -742,7 +763,7 @@ export type ListRepositoriesResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number; updatedAt: number;
}>; }>;
}; };
@@ -757,30 +778,51 @@ export type CreateRepositoryData = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
}; };
name: string; name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
@@ -865,30 +907,51 @@ export type GetRepositoryResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -896,7 +959,7 @@ export type GetRepositoryResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number; updatedAt: number;
}; };
}; };
@@ -929,6 +992,27 @@ export type ListSnapshotsResponses = {
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof 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 = { export type GetSnapshotDetailsData = {
body?: never; body?: never;
path: { path: {
@@ -1079,30 +1163,51 @@ export type ListBackupSchedulesResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1110,7 +1215,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1280,30 +1385,51 @@ export type GetBackupScheduleResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1311,7 +1437,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1462,30 +1588,51 @@ export type GetBackupScheduleForVolumeResponses = {
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accessKeyId: string; accessKeyId: string;
backend: 's3'; backend: 's3';
bucket: string; bucket: string;
endpoint: string; endpoint: string;
secretAccessKey: string; secretAccessKey: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
accountKey: string; accountKey: string;
accountName: string; accountName: string;
backend: 'azure'; backend: 'azure';
container: string; container: string;
customPassword?: string;
endpointSuffix?: string; endpointSuffix?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'gcs'; backend: 'gcs';
bucket: string; bucket: string;
credentialsJson: string; credentialsJson: string;
projectId: string; projectId: string;
customPassword?: string;
isExistingRepository?: boolean;
} | { } | {
backend: 'local'; backend: 'local';
name: string; name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1493,7 +1640,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1611,13 +1758,6 @@ export type RunForgetData = {
url: '/api/v1/backups/{scheduleId}/forget'; url: '/api/v1/backups/{scheduleId}/forget';
}; };
export type RunForgetErrors = {
/**
* No retention policy configured for this schedule
*/
400: unknown;
};
export type RunForgetResponses = { export type RunForgetResponses = {
/** /**
* Retention policy applied successfully * Retention policy applied successfully

View File

@@ -3,6 +3,7 @@ import { Link, NavLink } from "react-router";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter,
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarHeader, SidebarHeader,
@@ -13,6 +14,7 @@ import {
} from "~/client/components/ui/sidebar"; } from "~/client/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { APP_VERSION } from "~/client/lib/version";
const items = [ const items = [
{ {
@@ -85,6 +87,15 @@ export function AppSidebar() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className="p-4 border-r border-t border-border/50">
<div
className={cn("text-xs text-muted-foreground transition-all duration-200", {
"opacity-0 w-0 overflow-hidden": state === "collapsed",
})}
>
{APP_VERSION}
</div>
</SidebarFooter>
</Sidebar> </Sidebar>
); );
} }

View File

@@ -10,12 +10,23 @@ import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription } from "./ui/alert"; import { Alert, AlertDescription } from "./ui/alert";
import { ExternalLink } from "lucide-react"; import { ExternalLink, AlertTriangle } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic"; import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen"; import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
import { Checkbox } from "./ui/checkbox"; import { Checkbox } from "./ui/checkbox";
import { DirectoryBrowser } from "./directory-browser";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
@@ -41,6 +52,7 @@ const defaultValuesForType = {
gcs: { backend: "gcs" as const, compressionMode: "auto" as const }, gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
azure: { backend: "azure" as const, compressionMode: "auto" as const }, azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" 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 = ({ export const CreateRepositoryForm = ({
@@ -66,6 +78,8 @@ export const CreateRepositoryForm = ({
const watchedIsExistingRepository = watch("isExistingRepository"); const watchedIsExistingRepository = watch("isExistingRepository");
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default"); const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
const [showPathBrowser, setShowPathBrowser] = useState(false);
const [showPathWarning, setShowPathWarning] = useState(false);
const { capabilities } = useSystemInfo(); const { capabilities } = useSystemInfo();
@@ -126,6 +140,7 @@ export const CreateRepositoryForm = ({
<SelectItem value="r2">Cloudflare R2</SelectItem> <SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem> <SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem> <SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="rest">REST Server</SelectItem>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone"> <SelectItem disabled={!capabilities.rclone} value="rclone">
@@ -245,6 +260,87 @@ export const CreateRepositoryForm = ({
</> </>
)} )}
{watchedBackend === "local" && (
<>
<FormItem>
<FormLabel>Repository Directory</FormLabel>
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
{form.watch("path") || "/var/lib/ironmount/repositories"}
</div>
<Button
type="button"
variant="outline"
onClick={() => setShowPathWarning(true)}
size="sm"
>
Change
</Button>
</div>
<FormDescription>
The directory where the repository will be stored.
</FormDescription>
</FormItem>
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
Important: Host Mount Required
</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>
When selecting a custom path, ensure it is mounted from the host machine into the
container.
</p>
<p className="font-medium">
If the path is not a host mount, you will lose your repository data when the container
restarts.
</p>
<p className="text-sm text-muted-foreground">
The default path <code className="bg-muted px-1 rounded">/var/lib/ironmount/repositories</code> is
already mounted from the host and is safe to use.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowPathBrowser(true);
setShowPathWarning(false);
}}
>
I Understand, Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
<AlertDialogDescription>
Choose a directory from the filesystem to store the repository.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4">
<DirectoryBrowser
onSelectPath={(path) => form.setValue("path", path)}
selectedPath={form.watch("path") || "/var/lib/ironmount/repositories"}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>Done</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{watchedBackend === "s3" && ( {watchedBackend === "s3" && (
<> <>
<FormField <FormField
@@ -546,6 +642,67 @@ export const CreateRepositoryForm = ({
</> </>
))} ))}
{watchedBackend === "rest" && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>REST Server URL</FormLabel>
<FormControl>
<Input placeholder="http://192.168.1.30:8000" {...field} />
</FormControl>
<FormDescription>URL of the REST server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="my-backup-repo" {...field} />
</FormControl>
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormDescription>Username for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Password for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{mode === "update" && ( {mode === "update" && (
<Button type="submit" className="w-full" loading={loading}> <Button type="submit" className="w-full" loading={loading}>
Save Changes Save Changes

View File

@@ -207,7 +207,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Port</FormLabel> <FormLabel>Port</FormLabel>
<FormControl> <FormControl>
<Input type="number" placeholder="2049" {...field} /> <Input
type="number"
placeholder="2049"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl> </FormControl>
<FormDescription>NFS server port (default: 2049).</FormDescription> <FormDescription>NFS server port (default: 2049).</FormDescription>
<FormMessage /> <FormMessage />
@@ -332,7 +337,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem> <FormItem>
<FormLabel>Port</FormLabel> <FormLabel>Port</FormLabel>
<FormControl> <FormControl>
<Input type="number" placeholder="80" {...field} /> <Input
type="number"
placeholder="80"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl> </FormControl>
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription> <FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
<FormMessage /> <FormMessage />

View File

@@ -1,4 +1,4 @@
import { Database, HardDrive, Cloud } from "lucide-react"; import { Database, HardDrive, Cloud, Server } from "lucide-react";
import type { RepositoryBackend } from "~/schemas/restic"; import type { RepositoryBackend } from "~/schemas/restic";
type Props = { type Props = {
@@ -14,6 +14,8 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
return <Cloud className={className} />; return <Cloud className={className} />;
case "gcs": case "gcs":
return <Cloud className={className} />; return <Cloud className={className} />;
case "rest":
return <Server className={className} />;
default: default:
return <Database className={className} />; return <Database className={className} />;
} }

View File

@@ -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 { useNavigate } from "react-router";
import { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; 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 { formatDuration } from "~/utils/utils";
import type { ListSnapshotsResponse } from "../api-client"; 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]; type Snapshot = ListSnapshotsResponse[number];
@@ -15,81 +31,149 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(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) => { const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`); navigate(`/repositories/${repositoryName}/${snapshotId}`);
}; };
return ( return (
<div className="overflow-x-auto"> <>
<Table className="border-t"> <div className="overflow-x-auto">
<TableHeader className="bg-card-header"> <Table className="border-t">
<TableRow> <TableHeader className="bg-card-header">
<TableHead className="uppercase">Snapshot ID</TableHead> <TableRow>
<TableHead className="uppercase">Date & Time</TableHead> <TableHead className="uppercase">Snapshot ID</TableHead>
<TableHead className="uppercase">Size</TableHead> <TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead> <TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead> <TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
</TableRow> <TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
</TableHeader> <TableHead className="uppercase text-right">Actions</TableHead>
<TableBody>
{snapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {snapshots.map((snapshot) => (
</div> <TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
disabled={deleteSnapshot.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete snapshot
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
); );
}; };

View File

@@ -0,0 +1 @@
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev";

View File

@@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Download Recovery Key" }, { title: "Ironmount - Download Recovery Key" },
{ {
name: "description", name: "description",
content: "Download your backup recovery key to ensure you can restore your data.", content: "Download your backup recovery key to ensure you can restore your data.",

View File

@@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Login" }, { title: "Ironmount - Login" },
{ {
name: "description", name: "description",
content: "Sign in to your Ironmount account.", content: "Sign in to your Ironmount account.",

View File

@@ -24,7 +24,7 @@ export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Onboarding" }, { title: "Ironmount - Onboarding" },
{ {
name: "description", name: "description",
content: "Welcome to Ironmount. Create your admin account to get started.", content: "Welcome to Ironmount. Create your admin account to get started.",

View File

@@ -254,8 +254,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<CardHeader> <CardHeader>
<CardTitle>Backup paths</CardTitle> <CardTitle>Backup paths</CardTitle>
<CardDescription> <CardDescription>
Select which folders or files to include in the backup. If no paths are selected, the entire volume will be Select which folders or files to include in the backup. If no paths are selected, the entire volume will
backed up. be backed up.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -26,10 +26,12 @@ interface Props {
snapshot: Snapshot; snapshot: Snapshot;
repositoryName: string; repositoryName: string;
volume?: Volume; volume?: Volume;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
} }
export const SnapshotFileBrowser = (props: Props) => { 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; const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
@@ -136,30 +138,43 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardTitle>File Browser</CardTitle> <CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription> <CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div> </div>
{selectedPaths.size > 0 && ( <div className="flex gap-2">
<Tooltip> {selectedPaths.size > 0 && (
<TooltipTrigger asChild> <Tooltip>
<span tabIndex={isReadOnly ? 0 : undefined}> <TooltipTrigger asChild>
<Button <span tabIndex={isReadOnly ? 0 : undefined}>
onClick={handleRestoreClick} <Button
variant="primary" onClick={handleRestoreClick}
size="sm" variant="primary"
disabled={isRestoring || isReadOnly} size="sm"
> disabled={isRestoring || isReadOnly}
{isRestoring >
? "Restoring..." {isRestoring
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`} ? "Restoring..."
</Button> : `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
</span> </Button>
</TooltipTrigger> </span>
{isReadOnly && ( </TooltipTrigger>
<TooltipContent className="text-center"> {isReadOnly && (
<p>Volume is mounted as read-only.</p> <TooltipContent className="text-center">
<p>Please remount with read-only disabled to restore files.</p> <p>Volume is mounted as read-only.</p>
</TooltipContent> <p>Please remount with read-only disabled to restore files.</p>
)} </TooltipContent>
</Tooltip> )}
)} </Tooltip>
)}
{onDeleteSnapshot && (
<Button
variant="destructive"
size="sm"
onClick={() => onDeleteSnapshot(snapshot.short_id)}
disabled={isDeletingSnapshot}
loading={isDeletingSnapshot}
>
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
</Button>
)}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0"> <CardContent className="flex-1 overflow-hidden flex flex-col p-0">

View File

@@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router"; import { redirect, useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { import {
getBackupScheduleOptions, getBackupScheduleOptions,
runBackupNowMutation, runBackupNowMutation,
@@ -10,6 +20,7 @@ import {
listSnapshotsOptions, listSnapshotsOptions,
updateBackupScheduleMutation, updateBackupScheduleMutation,
stopBackupMutation, stopBackupMutation,
deleteSnapshotMutation,
} from "~/client/api-client/@tanstack/react-query.gen"; } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { getCronExpression } from "~/utils/utils"; import { getCronExpression } from "~/utils/utils";
@@ -29,7 +40,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Backup Job Details" }, { title: "Ironmount - Backup Job Details" },
{ {
name: "description", name: "description",
content: "View and manage backup job configuration, schedule, and snapshots.", content: "View and manage backup job configuration, schedule, and snapshots.",
@@ -50,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const formId = useId(); const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>(); const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const { data: schedule } = useQuery({ const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }), ...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) => { const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!schedule) return; 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) { if (isEditMode) {
return ( return (
<div> <div>
@@ -191,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
snapshot={selectedSnapshot} snapshot={selectedSnapshot}
repositoryName={schedule.repository.name} repositoryName={schedule.repository.name}
volume={schedule.volume} volume={schedule.volume}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}
/> />
)} )}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete snapshot
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View File

@@ -15,7 +15,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Backup Jobs" }, { title: "Ironmount - Backup Jobs" },
{ {
name: "description", name: "description",
content: "Automate volume backups with scheduled jobs and retention policies.", content: "Automate volume backups with scheduled jobs and retention policies.",

View File

@@ -24,7 +24,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Create Backup Job" }, { title: "Ironmount - Create Backup Job" },
{ {
name: "description", name: "description",
content: "Create a new automated backup job for your volumes.", content: "Create a new automated backup job for your volumes.",

View File

@@ -17,7 +17,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Create Repository" }, { title: "Ironmount - Create Repository" },
{ {
name: "description", name: "description",
content: "Create a new backup repository with encryption and compression.", content: "Create a new backup repository with encryption and compression.",

View File

@@ -20,7 +20,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Repositories" }, { title: "Ironmount - Repositories" },
{ {
name: "description", name: "description",
content: "Manage your backup repositories with encryption and compression.", content: "Manage your backup repositories with encryption and compression.",

View File

@@ -36,7 +36,7 @@ export const handle = {
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: params.name }, { title: `Ironmount - ${params.name}` },
{ {
name: "description", name: "description",
content: "View repository configuration, status, and snapshots.", content: "View repository configuration, status, and snapshots.",

View File

@@ -17,7 +17,7 @@ export const handle = {
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: `Snapshot ${params.snapshotId}` }, { title: `Ironmount - Snapshot ${params.snapshotId}` },
{ {
name: "description", name: "description",
content: "Browse and restore files from a backup snapshot.", content: "Browse and restore files from a backup snapshot.",

View File

@@ -30,7 +30,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Settings" }, { title: "Ironmount - Settings" },
{ {
name: "description", name: "description",
content: "Manage your account settings and preferences.", content: "Manage your account settings and preferences.",

View File

@@ -17,7 +17,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Create Volume" }, { title: "Ironmount - Create Volume" },
{ {
name: "description", name: "description",
content: "Create a new storage volume with automatic mounting and health checks.", content: "Create a new storage volume with automatic mounting and health checks.",

View File

@@ -37,7 +37,7 @@ export const handle = {
export function meta({ params }: Route.MetaArgs) { export function meta({ params }: Route.MetaArgs) {
return [ return [
{ title: params.name }, { title: `Ironmount - ${params.name}` },
{ {
name: "description", name: "description",
content: "View and manage volume details, configuration, and files.", content: "View and manage volume details, configuration, and files.",

View File

@@ -20,7 +20,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
{ title: "Volumes" }, { title: "Ironmount - Volumes" },
{ {
name: "description", name: "description",
content: "Create, manage, monitor, and automate your Docker volumes with ease.", content: "Create, manage, monitor, and automate your Docker volumes with ease.",

View File

@@ -7,6 +7,7 @@ export const REPOSITORY_BACKENDS = {
gcs: "gcs", gcs: "gcs",
azure: "azure", azure: "azure",
rclone: "rclone", rclone: "rclone",
rest: "rest",
} as const; } as const;
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS; export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
@@ -36,6 +37,7 @@ export const r2RepositoryConfigSchema = type({
export const localRepositoryConfigSchema = type({ export const localRepositoryConfigSchema = type({
backend: "'local'", backend: "'local'",
name: "string", name: "string",
path: "string?",
}).and(baseRepositoryConfigSchema); }).and(baseRepositoryConfigSchema);
export const gcsRepositoryConfigSchema = type({ export const gcsRepositoryConfigSchema = type({
@@ -59,12 +61,21 @@ export const rcloneRepositoryConfigSchema = type({
path: "string", path: "string",
}).and(baseRepositoryConfigSchema); }).and(baseRepositoryConfigSchema);
export const restRepositoryConfigSchema = type({
backend: "'rest'",
url: "string",
username: "string?",
password: "string?",
path: "string?",
}).and(baseRepositoryConfigSchema);
export const repositoryConfigSchema = s3RepositoryConfigSchema export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema) .or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema) .or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema) .or(gcsRepositoryConfigSchema)
.or(azureRepositoryConfigSchema) .or(azureRepositoryConfigSchema)
.or(rcloneRepositoryConfigSchema); .or(rcloneRepositoryConfigSchema)
.or(restRepositoryConfigSchema);
export type RepositoryConfig = typeof repositoryConfigSchema.infer; export type RepositoryConfig = typeof repositoryConfigSchema.infer;

View File

@@ -17,7 +17,7 @@ const calculateNextRun = (cronExpression: string): number => {
try { try {
const interval = CronExpressionParser.parse(cronExpression, { const interval = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(), currentDate: new Date(),
tz: "UTC", tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
}); });
return interval.next().getTime(); return interval.next().getTime();

View File

@@ -4,6 +4,7 @@ import {
createRepositoryBody, createRepositoryBody,
createRepositoryDto, createRepositoryDto,
deleteRepositoryDto, deleteRepositoryDto,
deleteSnapshotDto,
doctorRepositoryDto, doctorRepositoryDto,
getRepositoryDto, getRepositoryDto,
getSnapshotDetailsDto, getSnapshotDetailsDto,
@@ -16,6 +17,7 @@ import {
restoreSnapshotBody, restoreSnapshotBody,
restoreSnapshotDto, restoreSnapshotDto,
type DeleteRepositoryDto, type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DoctorRepositoryDto, type DoctorRepositoryDto,
type GetRepositoryDto, type GetRepositoryDto,
type GetSnapshotDetailsDto, type GetSnapshotDetailsDto,
@@ -142,4 +144,11 @@ export const repositoriesController = new Hono()
const result = await repositoriesService.doctorRepository(name); const result = await repositoriesService.doctorRepository(name);
return c.json<DoctorRepositoryDto>(result, 200); return c.json<DoctorRepositoryDto>(result, 200);
})
.delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
const { name, snapshotId } = c.req.param();
await repositoriesService.deleteSnapshot(name, snapshotId);
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
}); });

View File

@@ -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),
},
},
},
},
});

View File

@@ -33,6 +33,14 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
case "azure": case "azure":
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey); encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
break; break;
case "rest":
if (config.username) {
encryptedConfig.username = await cryptoUtils.encrypt(config.username);
}
if (config.password) {
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
}
break;
} }
return encryptedConfig as RepositoryConfig; return encryptedConfig as RepositoryConfig;
@@ -327,6 +335,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 = { export const repositoriesService = {
listRepositories, listRepositories,
createRepository, createRepository,
@@ -338,4 +358,5 @@ export const repositoriesService = {
getSnapshotDetails, getSnapshotDetails,
checkHealth, checkHealth,
doctorRepository, doctorRepository,
deleteSnapshot,
}; };

View File

@@ -71,7 +71,7 @@ const ensurePassfile = async () => {
const buildRepoUrl = (config: RepositoryConfig): string => { const buildRepoUrl = (config: RepositoryConfig): string => {
switch (config.backend) { switch (config.backend) {
case "local": case "local":
return `${REPOSITORY_BASE}/${config.name}`; return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`;
case "s3": case "s3":
return `s3:${config.endpoint}/${config.bucket}`; return `s3:${config.endpoint}/${config.bucket}`;
case "r2": { case "r2": {
@@ -84,6 +84,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
return `azure:${config.container}:/`; return `azure:${config.container}:/`;
case "rclone": case "rclone":
return `rclone:${config.remote}:${config.path}`; return `rclone:${config.remote}:${config.path}`;
case "rest": {
const path = config.path ? `/${config.path}` : "";
return `rest:${config.url}${path}`;
}
default: { default: {
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`); throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
} }
@@ -133,6 +137,15 @@ const buildEnv = async (config: RepositoryConfig) => {
} }
break; break;
} }
case "rest": {
if (config.username) {
env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username);
}
if (config.password) {
env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password);
}
break;
}
} }
return env; return env;
@@ -142,6 +155,9 @@ const init = async (config: RepositoryConfig) => {
await ensurePassfile(); await ensurePassfile();
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
logger.info(`Initializing restic repository at ${repoUrl}...`);
const env = await buildEnv(config); const env = await buildEnv(config);
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow(); const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
@@ -441,6 +457,22 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
return { success: true }; 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({ const lsNodeSchema = type({
name: "string", name: "string",
type: "string", type: "string",
@@ -601,6 +633,7 @@ export const restic = {
restore, restore,
snapshots, snapshots,
forget, forget,
deleteSnapshot,
unlock, unlock,
ls, ls,
check, check,

View File

@@ -4,7 +4,6 @@
"": { "": {
"name": "@ironmount/client", "name": "@ironmount/client",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.6",
"@hono/standard-validator": "^0.1.5", "@hono/standard-validator": "^0.1.5",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -23,8 +22,6 @@
"@react-router/serve": "^7.9.3", "@react-router/serve": "^7.9.3",
"@scalar/hono-api-reference": "^0.9.24", "@scalar/hono-api-reference": "^0.9.24",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"arktype": "^2.1.26", "arktype": "^2.1.26",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -60,6 +57,7 @@
"@hey-api/openapi-ts": "^0.87.4", "@hey-api/openapi-ts": "^0.87.4",
"@react-router/dev": "^7.9.3", "@react-router/dev": "^7.9.3",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/bun": "^1.3.2", "@types/bun": "^1.3.2",
"@types/dockerode": "^3.3.45", "@types/dockerode": "^3.3.45",
"@types/node": "^24.6.2", "@types/node": "^24.6.2",
@@ -482,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-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/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=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],

View File

@@ -15,6 +15,7 @@ services:
ports: ports:
- "4096:4096" - "4096:4096"
volumes: volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount - /var/lib/ironmount:/var/lib/ironmount
- ./app:/app/app - ./app:/app/app
@@ -37,6 +38,7 @@ services:
ports: ports:
- "4096:4096" - "4096:4096"
volumes: volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount:rshared - /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins - /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock

View File

@@ -17,7 +17,6 @@
"studio": "drizzle-kit studio" "studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.6",
"@hono/standard-validator": "^0.1.5", "@hono/standard-validator": "^0.1.5",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -36,8 +35,6 @@
"@react-router/serve": "^7.9.3", "@react-router/serve": "^7.9.3",
"@scalar/hono-api-reference": "^0.9.24", "@scalar/hono-api-reference": "^0.9.24",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"arktype": "^2.1.26", "arktype": "^2.1.26",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -73,6 +70,7 @@
"@hey-api/openapi-ts": "^0.87.4", "@hey-api/openapi-ts": "^0.87.4",
"@react-router/dev": "^7.9.3", "@react-router/dev": "^7.9.3",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/bun": "^1.3.2", "@types/bun": "^1.3.2",
"@types/dockerode": "^3.3.45", "@types/dockerode": "^3.3.45",
"@types/node": "^24.6.2", "@types/node": "^24.6.2",