Compare commits

..

9 Commits

Author SHA1 Message Date
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
Nicolas Meienberger
c0bef7f65e chore: update versions in readme 2025-11-15 12:41:53 +01:00
Nicolas Meienberger
29c96c9fc6 ci: don't create gh release for alpha and beta versions 2025-11-15 12:28:34 +01:00
33 changed files with 505 additions and 139 deletions

View File

@@ -74,10 +74,13 @@ 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
needs: [build-images] needs: [build-images, determine-release-type]
if: needs.determine-release-type.outputs.release_type == 'release'
outputs: outputs:
id: ${{ steps.create_release.outputs.id }} id: ${{ steps.create_release.outputs.id }}
steps: steps:

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.8 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.8 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.8 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.8 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.8 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

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,42 @@ 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;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -757,30 +769,42 @@ 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;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
name: string; name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
@@ -865,30 +889,42 @@ 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;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -929,6 +965,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 +1136,42 @@ 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;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1280,30 +1349,42 @@ 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;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1462,30 +1543,42 @@ 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;
} | { } | {
backend: 'rclone'; backend: 'rclone';
path: string; path: string;
remote: string; remote: string;
customPassword?: string;
isExistingRepository?: boolean;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1611,13 +1704,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

@@ -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,12 +31,46 @@ 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"> <div className="overflow-x-auto">
<Table className="border-t"> <Table className="border-t">
<TableHeader className="bg-card-header"> <TableHeader className="bg-card-header">
@@ -30,6 +80,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableHead className="uppercase">Size</TableHead> <TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead> <TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead> <TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
<TableHead className="uppercase text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -86,10 +137,43 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
</Tooltip> </Tooltip>
</div> </div>
</TableCell> </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> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </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,6 +138,7 @@ 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>
<div className="flex gap-2">
{selectedPaths.size > 0 && ( {selectedPaths.size > 0 && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -160,6 +163,18 @@ export const SnapshotFileBrowser = (props: Props) => {
)} )}
</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

@@ -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

@@ -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 = { export const repositoriesService = {
listRepositories, listRepositories,
createRepository, createRepository,
@@ -338,4 +350,5 @@ export const repositoriesService = {
getSnapshotDetails, getSnapshotDetails,
checkHealth, checkHealth,
doctorRepository, doctorRepository,
deleteSnapshot,
}; };

View File

@@ -441,6 +441,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 +617,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",