Compare commits

..

11 Commits

Author SHA1 Message Date
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
55 changed files with 804 additions and 1188 deletions

View File

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

View File

@@ -2,11 +2,8 @@ ARG BUN_VERSION="1.3.1"
FROM oven/bun:${BUN_VERSION}-alpine AS base
RUN apk add --no-cache \
davfs2=1.6.1-r2 \
mariadb-client \
mysql-client \
postgresql-client
RUN apk add --no-cache davfs2=1.6.1-r2
# ------------------------------
# DEPENDENCIES
@@ -62,6 +59,8 @@ CMD ["bun", "run", "dev"]
# ------------------------------
FROM oven/bun:${BUN_VERSION} AS builder
ARG APP_VERSION=dev
WORKDIR /app
COPY ./package.json ./bun.lock ./
@@ -69,6 +68,9 @@ RUN bun install --frozen-lockfile
COPY . .
RUN touch .env
RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env
RUN bun run build
FROM base AS production

View File

@@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -46,9 +46,13 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
```
> [!WARNING]
> Do not try to change the location of the bind mount `/var/lib/ironmount` on your host or store it on a network share. You will likely face permission issues and strong performance degradation.
Then, run the following command to start Ironmount:
```bash
@@ -68,7 +72,7 @@ If you want to track a local directory on the same server where Ironmount is run
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -78,6 +82,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
+ - /path/to/your/directory:/mydata
```
@@ -133,7 +138,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -143,6 +148,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/ironmount:/var/lib/ironmount
+ - ~/.config/rclone:/root/.config/rclone
```
@@ -189,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
ports:
@@ -197,6 +203,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
```
@@ -217,7 +224,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.9
image: ghcr.io/nicotsx/ironmount:v0.10
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -227,6 +234,7 @@ services:
devices:
- /dev/fuse:/dev/fuse
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/ironmount:/var/lib/ironmount
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
+ - /run/docker/plugins:/run/docker/plugins

View File

@@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
/**
* Register a new user
@@ -460,6 +460,23 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => que
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);
/**

View File

@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, 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> & {
/**
@@ -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
*/

View File

@@ -157,22 +157,6 @@ export type ListVolumesResponses = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -180,15 +164,6 @@ export type ListVolumesResponses = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -215,7 +190,7 @@ export type ListVolumesResponses = {
lastHealthCheck: number;
name: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
}>;
};
@@ -228,22 +203,6 @@ export type CreateVolumeData = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -251,15 +210,6 @@ export type CreateVolumeData = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -297,22 +247,6 @@ export type CreateVolumeResponses = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -320,15 +254,6 @@ export type CreateVolumeResponses = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -355,7 +280,7 @@ export type CreateVolumeResponses = {
lastHealthCheck: number;
name: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
};
};
@@ -368,22 +293,6 @@ export type TestConnectionData = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -391,15 +300,6 @@ export type TestConnectionData = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -490,22 +390,6 @@ export type GetVolumeResponses = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -513,15 +397,6 @@ export type GetVolumeResponses = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -548,7 +423,7 @@ export type GetVolumeResponses = {
lastHealthCheck: number;
name: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
};
};
@@ -563,22 +438,6 @@ export type UpdateVolumeData = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -586,15 +445,6 @@ export type UpdateVolumeData = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -640,22 +490,6 @@ export type UpdateVolumeResponses = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -663,15 +497,6 @@ export type UpdateVolumeResponses = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -698,7 +523,7 @@ export type UpdateVolumeResponses = {
lastHealthCheck: number;
name: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
};
};
@@ -916,12 +741,21 @@ export type ListRepositoriesResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -929,7 +763,7 @@ export type ListRepositoriesResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
}>;
};
@@ -974,12 +808,21 @@ export type CreateRepositoryData = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
@@ -1094,12 +937,21 @@ export type GetRepositoryResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -1107,7 +959,7 @@ export type GetRepositoryResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
};
};
@@ -1140,6 +992,27 @@ export type ListSnapshotsResponses = {
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
export type DeleteSnapshotData = {
body?: never;
path: {
name: string;
snapshotId: string;
};
query?: never;
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}';
};
export type DeleteSnapshotResponses = {
/**
* Snapshot deleted successfully
*/
200: {
message: string;
};
};
export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapshotResponses];
export type GetSnapshotDetailsData = {
body?: never;
path: {
@@ -1320,12 +1193,21 @@ export type ListBackupSchedulesResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -1333,7 +1215,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
};
repositoryId: string;
@@ -1353,22 +1235,6 @@ export type ListBackupSchedulesResponses = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -1376,15 +1242,6 @@ export type ListBackupSchedulesResponses = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -1411,7 +1268,7 @@ export type ListBackupSchedulesResponses = {
lastHealthCheck: number;
name: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
};
volumeId: number;
@@ -1558,12 +1415,21 @@ export type GetBackupScheduleResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -1571,7 +1437,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
};
repositoryId: string;
@@ -1591,22 +1457,6 @@ export type GetBackupScheduleResponses = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -1614,15 +1464,6 @@ export type GetBackupScheduleResponses = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -1649,7 +1490,7 @@ export type GetBackupScheduleResponses = {
lastHealthCheck: number;
name: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
};
volumeId: number;
@@ -1777,12 +1618,21 @@ export type GetBackupScheduleForVolumeResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -1790,7 +1640,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
};
repositoryId: string;
@@ -1810,22 +1660,6 @@ export type GetBackupScheduleForVolumeResponses = {
backend: 'directory';
path: string;
readOnly?: false;
} | {
backend: 'mariadb';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'mysql';
database: string;
host: string;
password: string;
username: string;
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'nfs';
exportPath: string;
@@ -1833,15 +1667,6 @@ export type GetBackupScheduleForVolumeResponses = {
version: '3' | '4' | '4.1';
port?: number;
readOnly?: boolean;
} | {
backend: 'postgres';
database: string;
host: string;
password: string;
username: string;
dumpFormat?: 'custom' | 'directory' | 'plain';
port?: number;
dumpOptions?: Array<string>;
} | {
backend: 'smb';
password: string;
@@ -1868,7 +1693,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastHealthCheck: number;
name: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
};
volumeId: number;

View File

@@ -3,6 +3,7 @@ import { Link, NavLink } from "react-router";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
@@ -13,6 +14,7 @@ import {
} from "~/client/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
import { cn } from "~/client/lib/utils";
import { APP_VERSION } from "~/client/lib/version";
const items = [
{
@@ -85,6 +87,15 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
</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>
);
}

View File

@@ -10,12 +10,23 @@ import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useQuery } from "@tanstack/react-query";
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 { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
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({
name: "2<=string<=32",
@@ -41,6 +52,7 @@ const defaultValuesForType = {
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const },
};
export const CreateRepositoryForm = ({
@@ -66,6 +78,8 @@ export const CreateRepositoryForm = ({
const watchedIsExistingRepository = watch("isExistingRepository");
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
const [showPathBrowser, setShowPathBrowser] = useState(false);
const [showPathWarning, setShowPathWarning] = useState(false);
const { capabilities } = useSystemInfo();
@@ -126,6 +140,7 @@ export const CreateRepositoryForm = ({
<SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="rest">REST Server</SelectItem>
<Tooltip>
<TooltipTrigger>
<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" && (
<>
<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" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes

View File

@@ -6,31 +6,13 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { DirectoryBrowser } from "./directory-browser";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { volumeConfigSchema } from "~/schemas/volumes";
import { testConnectionMutation } from "~/client/api-client/@tanstack/react-query.gen";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "~/client/components/ui/select";
import { Button } from "~/client/components/ui/button";
import { DirectoryBrowser } from "~/client/components/directory-browser";
const SUPPORTS_CONNECTION_TEST = ["nfs", "smb", "webdav", "mariadb", "mysql", "postgres"];
import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
export const formSchema = type({
name: "2<=string<=32",
@@ -53,9 +35,6 @@ const defaultValuesForType = {
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
webdav: { backend: "webdav" as const, port: 80, ssl: false },
mariadb: { backend: "mariadb" as const, port: 3306 },
mysql: { backend: "mysql" as const, port: 3306 },
postgres: { backend: "postgres" as const, port: 5432, dumpFormat: "custom" as const },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
@@ -102,7 +81,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const handleTestConnection = async () => {
const formValues = getValues();
if (SUPPORTS_CONNECTION_TEST.includes(formValues.backend)) {
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
testBackendConnection.mutate({
body: { config: formValues },
});
@@ -142,26 +121,15 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger className="w-[280px]">
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="directory">Directory</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Network Storage</SelectLabel>
<SelectItem value="nfs">NFS</SelectItem>
<SelectItem value="smb">SMB</SelectItem>
<SelectItem value="webdav">WebDAV</SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel>Databases</SelectLabel>
<SelectItem value="mariadb">MariaDB</SelectItem>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="postgres">PostgreSQL</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this volume.</FormDescription>
@@ -239,7 +207,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="2049" {...field} />
<Input
type="number"
placeholder="2049"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>NFS server port (default: 2049).</FormDescription>
<FormMessage />
@@ -364,7 +337,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="80" {...field} />
<Input
type="number"
placeholder="80"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
<FormMessage />
@@ -568,258 +546,6 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</>
)}
{watchedBackend === "mariadb" && (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="localhost" {...field} />
</FormControl>
<FormDescription>MariaDB server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={3306}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="3306" {...field} />
</FormControl>
<FormDescription>MariaDB server port (default: 3306).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormDescription>Database user with backup privileges.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Password for database authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="database"
render={({ field }) => (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input placeholder="myapp_production" {...field} />
</FormControl>
<FormDescription>Name of the database to backup.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "mysql" && (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="localhost" {...field} />
</FormControl>
<FormDescription>MySQL server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={3306}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="3306" {...field} />
</FormControl>
<FormDescription>MySQL server port (default: 3306).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormDescription>Database user with backup privileges.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Password for database authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="database"
render={({ field }) => (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input placeholder="myapp_production" {...field} />
</FormControl>
<FormDescription>Name of the database to backup.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "postgres" && (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="localhost" {...field} />
</FormControl>
<FormDescription>PostgreSQL server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
defaultValue={5432}
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="5432" {...field} />
</FormControl>
<FormDescription>PostgreSQL server port (default: 5432).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="postgres" {...field} />
</FormControl>
<FormDescription>Database user with backup privileges.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Password for database authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="database"
render={({ field }) => (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input placeholder="myapp_production" {...field} />
</FormControl>
<FormDescription>Name of the database to backup.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dumpFormat"
defaultValue="custom"
render={({ field }) => (
<FormItem>
<FormLabel>Dump Format</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value || "custom"}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select dump format" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="custom">Custom (Compressed)</SelectItem>
<SelectItem value="plain">Plain SQL</SelectItem>
<SelectItem value="directory">Directory</SelectItem>
</SelectContent>
</Select>
<FormDescription>Format for database dumps (custom recommended).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend !== "directory" && (
<div className="space-y-3">
<div className="flex items-center gap-2">

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";
type Props = {
@@ -14,6 +14,8 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
return <Cloud className={className} />;
case "gcs":
return <Cloud className={className} />;
case "rest":
return <Server className={className} />;
default:
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 { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { formatDuration } from "~/utils/utils";
import type { ListSnapshotsResponse } from "../api-client";
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
type Snapshot = ListSnapshotsResponse[number];
@@ -15,12 +31,46 @@ type Props = {
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
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) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`);
};
return (
<>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
@@ -30,6 +80,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableHead className="uppercase">Size</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 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -86,10 +137,43 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
</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

@@ -1,4 +1,4 @@
import { Cloud, Database, Folder, Server, Share2 } from "lucide-react";
import { Cloud, Folder, Server, Share2 } from "lucide-react";
import type { BackendType } from "~/schemas/volumes";
type VolumeIconProps = {
@@ -32,24 +32,6 @@ const getIconAndColor = (backend: BackendType) => {
color: "text-green-600 dark:text-green-400",
label: "WebDAV",
};
case "mariadb":
return {
icon: Database,
color: "text-teal-600 dark:text-teal-400",
label: "MariaDB",
};
case "mysql":
return {
icon: Database,
color: "text-cyan-600 dark:text-cyan-400",
label: "MySQL",
};
case "postgres":
return {
icon: Database,
color: "text-indigo-600 dark:text-indigo-400",
label: "PostgreSQL",
};
default:
return {
icon: Folder,

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) {
return [
{ title: "Download Recovery Key" },
{ title: "Ironmount - Download Recovery Key" },
{
name: "description",
content: "Download your backup recovery key to ensure you can restore your data.",

View File

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

View File

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

View File

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

View File

@@ -26,10 +26,12 @@ interface Props {
snapshot: Snapshot;
repositoryName: string;
volume?: Volume;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName, volume } = props;
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
@@ -136,6 +138,7 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div>
<div className="flex gap-2">
{selectedPaths.size > 0 && (
<Tooltip>
<TooltipTrigger asChild>
@@ -160,6 +163,18 @@ export const SnapshotFileBrowser = (props: Props) => {
)}
</Tooltip>
)}
{onDeleteSnapshot && (
<Button
variant="destructive"
size="sm"
onClick={() => onDeleteSnapshot(snapshot.short_id)}
disabled={isDeletingSnapshot}
loading={isDeletingSnapshot}
>
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
</Button>
)}
</div>
</div>
</CardHeader>
<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 { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
getBackupScheduleOptions,
runBackupNowMutation,
@@ -10,6 +20,7 @@ import {
listSnapshotsOptions,
updateBackupScheduleMutation,
stopBackupMutation,
deleteSnapshotMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import { getCronExpression } from "~/utils/utils";
@@ -29,7 +40,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) {
return [
{ title: "Backup Job Details" },
{ title: "Ironmount - Backup Job Details" },
{
name: "description",
content: "View and manage backup job configuration, schedule, and snapshots.",
@@ -50,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
@@ -110,6 +123,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
},
});
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
onSuccess: () => {
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
if (selectedSnapshotId === snapshotToDelete) {
setSelectedSnapshotId(undefined);
}
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!schedule) return;
@@ -150,6 +174,26 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
});
};
const handleDeleteSnapshot = (snapshotId: string) => {
setSnapshotToDelete(snapshotId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: schedule.repository.name, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
success: "Snapshot deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
},
);
}
};
if (isEditMode) {
return (
<div>
@@ -191,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
snapshot={selectedSnapshot}
repositoryName={schedule.repository.name}
volume={schedule.volume}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}
/>
)}
<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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,12 @@ import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-volume";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateVolumeForm, type FormValues } from "../components/create-volume-form";
export const handle = {
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
@@ -17,7 +17,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) {
return [
{ title: "Create Volume" },
{ title: "Ironmount - Create Volume" },
{
name: "description",
content: "Create a new storage volume with automatic mounting and health checks.",

View File

@@ -37,7 +37,7 @@ export const handle = {
export function meta({ params }: Route.MetaArgs) {
return [
{ title: params.name },
{ title: `Ironmount - ${params.name}` },
{
name: "description",
content: "View and manage volume details, configuration, and files.",
@@ -119,8 +119,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { volume, statfs } = data;
const dockerAvailable = capabilities.docker;
const isDatabaseVolume = ["mariadb", "mysql", "postgres"].includes(volume.config.backend);
return (
<>
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
@@ -154,9 +152,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger disabled={isDatabaseVolume} value="files">
Files
</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<Tooltip>
<TooltipTrigger>
<TabsTrigger disabled={!dockerAvailable} value="docker">
@@ -171,11 +167,9 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
<TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent>
{!isDatabaseVolume && (
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
)}
{dockerAvailable && (
<TabsContent value="docker">
<DockerTabContent volume={volume} />

View File

@@ -20,7 +20,7 @@ export const handle = {
export function meta(_: Route.MetaArgs) {
return [
{ title: "Volumes" },
{ title: "Ironmount - Volumes" },
{
name: "description",
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
@@ -109,10 +109,6 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
<SelectItem value="directory">Directory</SelectItem>
<SelectItem value="nfs">NFS</SelectItem>
<SelectItem value="smb">SMB</SelectItem>
<SelectItem value="webdav">WebDAV</SelectItem>
<SelectItem value="mariadb">MariaDB</SelectItem>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="postgres">PostgreSQL</SelectItem>
</SelectContent>
</Select>
{(searchQuery || statusFilter || backendFilter) && (

View File

@@ -1,6 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
import {
AlertDialog,
AlertDialogAction,
@@ -16,7 +17,6 @@ import type { StatFs, Volume } from "~/client/lib/types";
import { HealthchecksCard } from "../components/healthchecks-card";
import { StorageChart } from "../components/storage-chart";
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "../components/create-volume-form";
type Props = {
volume: Volume;

View File

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

View File

@@ -5,9 +5,6 @@ export const BACKEND_TYPES = {
smb: "smb",
directory: "directory",
webdav: "webdav",
mariadb: "mariadb",
mysql: "mysql",
postgres: "postgres",
} as const;
export type BackendType = keyof typeof BACKEND_TYPES;
@@ -50,47 +47,7 @@ export const webdavConfigSchema = type({
ssl: "boolean?",
});
export const mariadbConfigSchema = type({
backend: "'mariadb'",
host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(3306),
username: "string",
password: "string",
database: "string",
dumpOptions: "string[]?",
readOnly: "false?",
});
export const mysqlConfigSchema = type({
backend: "'mysql'",
host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(3306),
username: "string",
password: "string",
database: "string",
dumpOptions: "string[]?",
readOnly: "false?",
});
export const postgresConfigSchema = type({
backend: "'postgres'",
host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(5432),
username: "string",
password: "string",
database: "string",
dumpFormat: type("'plain' | 'custom' | 'directory'").default("custom"),
dumpOptions: "string[]?",
readOnly: "false?",
});
export const volumeConfigSchema = nfsConfigSchema
.or(smbConfigSchema)
.or(webdavConfigSchema)
.or(directoryConfigSchema)
.or(mariadbConfigSchema)
.or(mysqlConfigSchema)
.or(postgresConfigSchema);
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
export type BackendConfig = typeof volumeConfigSchema.infer;

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import fs from "node:fs/promises";
import { volumeService } from "../modules/volumes/volume.service";
import { readMountInfo } from "../utils/mountinfo";
import { createVolumeBackend } from "../modules/backends/backend";
import { getVolumePath } from "../modules/volumes/helpers";
import { logger } from "../utils/logger";
import { executeUnmount } from "../modules/backends/utils/backend-utils";
import { toMessage } from "../utils/errors";
@@ -16,11 +16,7 @@ export class CleanupDanglingMountsJob extends Job {
for (const mount of allSystemMounts) {
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
const matchingVolume = allVolumes.find((v) => {
const backend = createVolumeBackend(v);
return backend.getVolumePath() === mount.mountPoint;
});
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
if (!matchingVolume) {
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
await executeUnmount(mount.mountPoint).catch((err) => {
@@ -40,10 +36,7 @@ export class CleanupDanglingMountsJob extends Job {
for (const dir of allIronmountDirs) {
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
const matchingVolume = allVolumes.find((v) => {
const backend = createVolumeBackend(v);
return backend.getVolumePath() === volumePath;
});
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === volumePath);
if (!matchingVolume) {
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);

View File

@@ -1,12 +1,10 @@
import type { BackendStatus } from "~/schemas/volumes";
import type { Volume } from "../../db/schema";
import { getVolumePath } from "../volumes/helpers";
import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend";
import { makeSmbBackend } from "./smb/smb-backend";
import { makeWebdavBackend } from "./webdav/webdav-backend";
import { makeMariaDBBackend } from "./mariadb/mariadb-backend";
import { makeMySQLBackend } from "./mysql/mysql-backend";
import { makePostgresBackend } from "./postgres/postgres-backend";
type OperationResult = {
error?: string;
@@ -17,35 +15,23 @@ export type VolumeBackend = {
mount: () => Promise<OperationResult>;
unmount: () => Promise<OperationResult>;
checkHealth: () => Promise<OperationResult>;
getVolumePath: () => string;
getBackupPath: () => Promise<string>;
};
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
const path = getVolumePath(volume);
switch (volume.config.backend) {
case "nfs": {
return makeNfsBackend(volume.config, volume.name);
return makeNfsBackend(volume.config, path);
}
case "smb": {
return makeSmbBackend(volume.config, volume.name);
return makeSmbBackend(volume.config, path);
}
case "directory": {
return makeDirectoryBackend(volume.config, volume.name);
return makeDirectoryBackend(volume.config, path);
}
case "webdav": {
return makeWebdavBackend(volume.config, volume.name);
}
case "mariadb": {
return makeMariaDBBackend(volume.config);
}
case "mysql": {
return makeMySQLBackend(volume.config);
}
case "postgres": {
return makePostgresBackend(volume.config);
}
default: {
throw new Error(`Unsupported backend type: ${(volume.config as any).backend}`);
return makeWebdavBackend(volume.config, path);
}
}
};

View File

@@ -52,18 +52,8 @@ const checkHealth = async (config: BackendConfig) => {
}
};
const getVolumePath = (config: BackendConfig): string => {
if (config.backend !== "directory") {
throw new Error("Invalid backend type");
}
return config.path;
};
export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({
mount: () => mount(config, volumePath),
unmount,
checkHealth: () => checkHealth(config),
getVolumePath: () => getVolumePath(config),
getBackupPath: async () => getVolumePath(config),
});

View File

@@ -1,81 +0,0 @@
import * as fs from "node:fs/promises";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import type { VolumeBackend } from "../backend";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
import { $ } from "bun";
const checkHealth = async (config: BackendConfig) => {
if (config.backend !== "mariadb") {
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
}
try {
logger.debug(`Testing MariaDB connection to: ${config.host}:${config.port}`);
const args = [
`--host=${config.host}`,
`--port=${config.port}`,
`--user=${config.username}`,
`--database=${config.database}`,
"--skip-ssl",
"--execute=SELECT 1",
];
const env = {
MYSQL_PWD: config.password,
};
await $`mariadb ${args.join(" ")}`.env(env);
return { status: BACKEND_STATUS.mounted };
} catch (error) {
logger.error("MariaDB health check failed:", error);
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const getBackupPath = async (config: BackendConfig) => {
const dumpDir = await fs.mkdtemp(`/tmp/ironmount-mariadb-`);
if (config.backend !== "mariadb") {
throw new Error("Invalid backend type for MariaDB dump");
}
logger.info(`Starting MariaDB dump for database: ${config.database}`);
const args = [
`--host=${config.host}`,
`--port=${config.port}`,
`--user=${config.username}`,
`--skip-ssl`,
`--single-transaction`,
`--quick`,
`--lock-tables=false`,
...(config.dumpOptions || []),
config.database,
];
const env = {
MYSQL_PWD: config.password,
};
const result = await $`mariadb-dump ${args}`.env(env).nothrow();
if (result.exitCode !== 0) {
throw new Error(`mariadb-dump failed with exit code ${result.exitCode}: ${result.stderr}`);
}
await fs.writeFile(`${dumpDir}/dump.sql`, result.stdout);
logger.info(`MariaDB dump completed: ${dumpDir}/dump.sql`);
return `${dumpDir}/dump.sql`;
};
export const makeMariaDBBackend = (config: BackendConfig): VolumeBackend => ({
mount: () => Promise.resolve({ status: BACKEND_STATUS.mounted }),
unmount: () => Promise.resolve({ status: BACKEND_STATUS.unmounted }),
checkHealth: () => checkHealth(config),
getVolumePath: () => "/tmp",
getBackupPath: () => getBackupPath(config),
});

View File

@@ -1,76 +0,0 @@
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import type { VolumeBackend } from "../backend";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
import { $ } from "bun";
const checkHealth = async (config: BackendConfig) => {
if (config.backend !== "mysql") {
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
}
logger.debug(`Testing MySQL connection to: ${config.host}:${config.port}`);
try {
const args = [
`--host=${config.host}`,
`--port=${config.port}`,
`--user=${config.username}`,
`--database=${config.database}`,
"--skip-ssl",
"--execute=SELECT 1",
];
const env = {
...process.env,
MYSQL_PWD: config.password,
};
await $`mysql ${args.join(" ")}`.env(env);
return { status: BACKEND_STATUS.mounted };
} catch (error) {
logger.error("MySQL health check failed:", error);
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const getBackupPath = async (config: BackendConfig) => {
if (config.backend !== "mysql") {
throw new Error("Invalid backend type");
}
logger.info(`Starting MySQL dump for database: ${config.database}`);
const args = [
`--host=${config.host}`,
`--port=${config.port}`,
`--user=${config.username}`,
`--skip-ssl`,
`--single-transaction`,
`--quick`,
`--lock-tables=false`,
...(config.dumpOptions || []),
config.database,
];
const env = {
MYSQL_PWD: config.password,
};
const result = await $`mysql ${args}`.env(env).nothrow();
if (result.exitCode !== 0) {
throw new Error(`MySQL dump failed: ${result.stderr}`);
}
console.log(result.stdout);
return "Nothing for now";
};
export const makeMySQLBackend = (config: BackendConfig): VolumeBackend => ({
mount: () => Promise.resolve({ status: BACKEND_STATUS.mounted }),
unmount: () => Promise.resolve({ status: BACKEND_STATUS.unmounted }),
checkHealth: () => checkHealth(config),
getVolumePath: () => "/tmp",
getBackupPath: () => getBackupPath(config),
});

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
@@ -9,8 +9,7 @@ import type { VolumeBackend } from "../backend";
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, name: string) => {
const path = getVolumePath(name);
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting volume ${path}...`);
if (config.backend !== "nfs") {
@@ -23,13 +22,13 @@ const mount = async (config: BackendConfig, name: string) => {
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(name, config.readOnly ?? false);
const { status } = await checkHealth(path, config.readOnly ?? false);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
await unmount(name);
await unmount(path);
const run = async () => {
await fs.mkdir(path, { recursive: true });
@@ -58,9 +57,7 @@ const mount = async (config: BackendConfig, name: string) => {
}
};
const unmount = async (name: string) => {
const path = getVolumePath(name);
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("NFS unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "NFS unmounting is only supported on Linux hosts." };
@@ -90,9 +87,7 @@ const unmount = async (name: string) => {
}
};
const checkHealth = async (name: string, readOnly: boolean) => {
const path = getVolumePath(name);
const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => {
logger.debug(`Checking health of NFS volume at ${path}...`);
await fs.access(path);
@@ -119,14 +114,8 @@ const checkHealth = async (name: string, readOnly: boolean) => {
}
};
const getVolumePath = (name: string) => {
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
};
export const makeNfsBackend = (config: BackendConfig, name: string): VolumeBackend => ({
mount: () => mount(config, name),
unmount: () => unmount(name),
checkHealth: () => checkHealth(name, config.readOnly ?? false),
getVolumePath: () => getVolumePath(name),
getBackupPath: async () => getVolumePath(name),
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -1,80 +0,0 @@
import * as fs from "node:fs/promises";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import type { VolumeBackend } from "../backend";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
import { $ } from "bun";
const checkHealth = async (config: BackendConfig) => {
if (config.backend !== "postgres") {
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
}
if (config.backend !== "postgres") {
throw new Error("Invalid backend type for PostgreSQL connection test");
}
logger.debug(`Testing PostgreSQL connection to: ${config.host}:${config.port}`);
const args = [
`--host=${config.host}`,
`--port=${config.port}`,
`--username=${config.username}`,
`--dbname=${config.database}`,
"--command=SELECT 1",
"--no-password",
];
const env = {
PGPASSWORD: config.password,
PGSSLMODE: "disable",
};
logger.debug(`Running psql with args: ${args.join(" ")}`);
const res = await $`psql ${args}`.env(env).nothrow();
if (res.exitCode !== 0) {
return { status: BACKEND_STATUS.error, error: res.stderr.toString() };
}
return { status: BACKEND_STATUS.mounted };
};
const getBackupPath = async (config: BackendConfig) => {
if (config.backend !== "postgres") {
throw new Error("Invalid backend type for PostgreSQL dump");
}
const dumpDir = await fs.mkdtemp(`/tmp/ironmount-postgres-`);
const outputPath = `${dumpDir}/${config.dumpFormat === "plain" ? "dump.sql" : "dump.dump"}`;
logger.info(`Starting PostgreSQL dump for database: ${config.database}`);
const args = [
`--host=${config.host}`,
`--port=${config.port}`,
`--username=${config.username}`,
`--dbname=${config.database}`,
`--format=${config.dumpFormat}`,
`--file=${outputPath}`,
"--no-password",
...(config.dumpOptions || []),
];
const env = {
PGPASSWORD: config.password,
PGSSLMODE: "disable",
};
await $`pg_dump ${args}`.env(env);
return outputPath;
};
export const makePostgresBackend = (config: BackendConfig): VolumeBackend => ({
mount: () => Promise.resolve({ status: "mounted" }),
unmount: () => Promise.resolve({ status: "unmounted" }),
checkHealth: () => checkHealth(config),
getVolumePath: () => "/tmp",
getBackupPath: () => getBackupPath(config),
});

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
@@ -9,8 +9,7 @@ import type { VolumeBackend } from "../backend";
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, name: string) => {
const path = getVolumePath(name);
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting SMB volume ${path}...`);
if (config.backend !== "smb") {
@@ -23,13 +22,13 @@ const mount = async (config: BackendConfig, name: string) => {
return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(name, config.readOnly ?? false);
const { status } = await checkHealth(path, config.readOnly ?? false);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
await unmount(name);
await unmount(path);
const run = async () => {
await fs.mkdir(path, { recursive: true });
@@ -71,9 +70,7 @@ const mount = async (config: BackendConfig, name: string) => {
}
};
const unmount = async (name: string) => {
const path = getVolumePath(name);
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("SMB unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "SMB unmounting is only supported on Linux hosts." };
@@ -103,9 +100,7 @@ const unmount = async (name: string) => {
}
};
const checkHealth = async (name: string, readOnly: boolean) => {
const path = getVolumePath(name);
const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => {
logger.debug(`Checking health of SMB volume at ${path}...`);
await fs.access(path);
@@ -132,14 +127,8 @@ const checkHealth = async (name: string, readOnly: boolean) => {
}
};
const getVolumePath = (name: string) => {
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
};
export const makeSmbBackend = (config: BackendConfig, name: string): VolumeBackend => ({
mount: () => mount(config, name),
unmount: () => unmount(name),
checkHealth: () => checkHealth(name, config.readOnly ?? false),
getVolumePath: () => getVolumePath(name),
getBackupPath: async () => getVolumePath(name),
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -2,7 +2,7 @@ import { execFile as execFileCb } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import { promisify } from "node:util";
import { OPERATION_TIMEOUT, VOLUME_MOUNT_BASE } from "../../../core/constants";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
@@ -13,8 +13,7 @@ import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const execFile = promisify(execFileCb);
const mount = async (config: BackendConfig, name: string) => {
const path = getVolumePath(name);
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting WebDAV volume ${path}...`);
if (config.backend !== "webdav") {
@@ -105,8 +104,7 @@ const mount = async (config: BackendConfig, name: string) => {
}
};
const unmount = async (name: string) => {
const path = getVolumePath(name);
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("WebDAV unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "WebDAV unmounting is only supported on Linux hosts." };
@@ -136,9 +134,7 @@ const unmount = async (name: string) => {
}
};
const checkHealth = async (name: string, readOnly: boolean) => {
const path = getVolumePath(name);
const checkHealth = async (path: string, readOnly: boolean) => {
const run = async () => {
logger.debug(`Checking health of WebDAV volume at ${path}...`);
await fs.access(path);
@@ -165,14 +161,8 @@ const checkHealth = async (name: string, readOnly: boolean) => {
}
};
const getVolumePath = (name: string) => {
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
};
export const makeWebdavBackend = (config: BackendConfig, name: string): VolumeBackend => ({
mount: () => mount(config, name),
unmount: () => unmount(name),
checkHealth: () => checkHealth(name, config.readOnly ?? false),
getVolumePath: () => getVolumePath(name),
getBackupPath: async () => getVolumePath(name),
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
mount: () => mount(config, path),
unmount: () => unmount(path),
checkHealth: () => checkHealth(path, config.readOnly ?? false),
});

View File

@@ -6,7 +6,7 @@ import { db } from "../../db/db";
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
import { restic } from "../../utils/restic";
import { logger } from "../../utils/logger";
import { createVolumeBackend } from "../backends/backend";
import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors";
import { serverEvents } from "../../core/events";
@@ -17,7 +17,7 @@ const calculateNextRun = (cronExpression: string): number => {
try {
const interval = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(),
tz: "UTC",
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
return interval.next().getTime();
@@ -206,8 +206,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
runningBackups.set(scheduleId, abortController);
try {
const backend = createVolumeBackend(volume);
const backupPath = await backend.getBackupPath();
const volumePath = getVolumePath(volume);
const backupOptions: {
exclude?: string[];
@@ -227,7 +226,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.include = schedule.includePatterns;
}
await restic.backup(repository.config, backupPath, {
await restic.backup(repository.config, volumePath, {
...backupOptions,
onProgress: (progress) => {
serverEvents.emit("backup:progress", {

View File

@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { volumeService } from "../volumes/volume.service";
import { createVolumeBackend } from "../backends/backend";
import { getVolumePath } from "../volumes/helpers";
export const driverController = new Hono()
.post("/VolumeDriver.Capabilities", (c) => {
@@ -31,11 +31,9 @@ export const driverController = new Hono()
}
const volumeName = body.Name.replace(/^im-/, "");
const { volume } = await volumeService.getVolume(volumeName);
const backend = createVolumeBackend(volume);
return c.json({
Mountpoint: backend.getVolumePath(),
Mountpoint: getVolumePath(volumeName),
});
})
.post("/VolumeDriver.Unmount", (c) => {
@@ -51,10 +49,9 @@ export const driverController = new Hono()
}
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
const backend = createVolumeBackend(volume);
return c.json({
Mountpoint: backend.getVolumePath(),
Mountpoint: getVolumePath(volume),
});
})
.post("/VolumeDriver.Get", async (c) => {
@@ -65,12 +62,11 @@ export const driverController = new Hono()
}
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
const backend = createVolumeBackend(volume);
return c.json({
Volume: {
Name: `im-${volume.name}`,
Mountpoint: backend.getVolumePath(),
Mountpoint: getVolumePath(volume),
Status: {},
},
Err: "",
@@ -79,16 +75,11 @@ export const driverController = new Hono()
.post("/VolumeDriver.List", async (c) => {
const volumes = await volumeService.listVolumes();
let res = [];
for (const volume of volumes) {
const backend = createVolumeBackend(volume);
res.push({
const res = volumes.map((volume) => ({
Name: `im-${volume.name}`,
Mountpoint: backend.getVolumePath(),
Mountpoint: getVolumePath(volume),
Status: {},
});
}
}));
return c.json({
Volumes: res,

View File

@@ -4,6 +4,7 @@ import {
createRepositoryBody,
createRepositoryDto,
deleteRepositoryDto,
deleteSnapshotDto,
doctorRepositoryDto,
getRepositoryDto,
getSnapshotDetailsDto,
@@ -16,6 +17,7 @@ import {
restoreSnapshotBody,
restoreSnapshotDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DoctorRepositoryDto,
type GetRepositoryDto,
type GetSnapshotDetailsDto,
@@ -142,4 +144,11 @@ export const repositoriesController = new Hono()
const result = await repositoriesService.doctorRepository(name);
return c.json<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":
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
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;
@@ -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 = {
listRepositories,
createRepository,
@@ -338,4 +358,5 @@ export const repositoriesService = {
getSnapshotDetails,
checkHealth,
doctorRepository,
deleteSnapshot,
};

View File

@@ -0,0 +1,10 @@
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import type { Volume } from "../../db/schema";
export const getVolumePath = (volume: Volume) => {
if (volume.config.backend === "directory") {
return volume.config.path;
}
return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
};

View File

@@ -25,7 +25,7 @@ import {
type BrowseFilesystemDto,
} from "./volume.dto";
import { volumeService } from "./volume.service";
import { createVolumeBackend } from "../backends/backend";
import { getVolumePath } from "./helpers";
export const volumeController = new Hono()
.get("/", listVolumesDto, async (c) => {
@@ -37,10 +37,9 @@ export const volumeController = new Hono()
const body = c.req.valid("json");
const res = await volumeService.createVolume(body.name, body.config);
const backend = createVolumeBackend(res.volume);
const response = {
...res.volume,
path: backend.getVolumePath(),
path: getVolumePath(res.volume),
};
return c.json<CreateVolumeDto>(response, 201);
@@ -61,11 +60,10 @@ export const volumeController = new Hono()
const { name } = c.req.param();
const res = await volumeService.getVolume(name);
const backend = createVolumeBackend(res.volume);
const response = {
volume: {
...res.volume,
path: backend.getVolumePath(),
path: getVolumePath(res.volume),
},
statfs: {
total: res.statfs.total ?? 0,
@@ -87,10 +85,9 @@ export const volumeController = new Hono()
const body = c.req.valid("json");
const res = await volumeService.updateVolume(name, body);
const backend = createVolumeBackend(res.volume);
const response = {
...res.volume,
path: backend.getVolumePath(),
path: getVolumePath(res.volume),
};
return c.json<UpdateVolumeDto>(response, 200);

View File

@@ -13,6 +13,7 @@ import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { withTimeout } from "../../utils/timeout";
import { createVolumeBackend } from "../backends/backend";
import type { UpdateVolumeBody } from "./volume.dto";
import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes";
@@ -128,9 +129,7 @@ const getVolume = async (name: string) => {
let statfs: Partial<StatFs> = {};
if (volume.status === "mounted") {
const backend = createVolumeBackend(volume);
const volumePath = backend.getVolumePath();
statfs = await withTimeout(getStatFs(volumePath), 1000, "getStatFs").catch((error) => {
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
return {};
});
@@ -204,16 +203,7 @@ const testConnection = async (backendConfig: BackendConfig) => {
};
const backend = createVolumeBackend(mockVolume);
let error: string | null = null;
const mount = await backend.mount();
if (mount.error) {
error = mount.error;
} else {
const health = await backend.checkHealth();
if (health.error) {
error = health.error;
}
}
const { error } = await backend.mount();
await backend.unmount();
@@ -305,8 +295,8 @@ const listFiles = async (name: string, subPath?: string) => {
throw new InternalServerError("Volume is not mounted");
}
const backend = createVolumeBackend(volume);
const volumePath = backend.getVolumePath();
// For directory volumes, use the configured path directly
const volumePath = getVolumePath(volume);
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;

View File

@@ -71,7 +71,7 @@ const ensurePassfile = async () => {
const buildRepoUrl = (config: RepositoryConfig): string => {
switch (config.backend) {
case "local":
return `${REPOSITORY_BASE}/${config.name}`;
return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`;
case "s3":
return `s3:${config.endpoint}/${config.bucket}`;
case "r2": {
@@ -84,6 +84,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
return `azure:${config.container}:/`;
case "rclone":
return `rclone:${config.remote}:${config.path}`;
case "rest": {
const path = config.path ? `/${config.path}` : "";
return `rest:${config.url}${path}`;
}
default: {
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
}
@@ -133,6 +137,15 @@ const buildEnv = async (config: RepositoryConfig) => {
}
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;
@@ -142,6 +155,9 @@ const init = async (config: RepositoryConfig) => {
await ensurePassfile();
const repoUrl = buildRepoUrl(config);
logger.info(`Initializing restic repository at ${repoUrl}...`);
const env = await buildEnv(config);
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 };
};
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
const res = await $`restic ${args}`.env(env).nothrow();
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
throw new Error(`Failed to delete snapshot: ${res.stderr}`);
}
return { success: true };
};
const lsNodeSchema = type({
name: "string",
type: "string",
@@ -601,6 +633,7 @@ export const restic = {
restore,
snapshots,
forget,
deleteSnapshot,
unlock,
ls,
check,

View File

@@ -3,10 +3,6 @@
* This removes passwords and credentials from logs and error messages
*/
export const sanitizeSensitiveData = (text: string): string => {
if (process.env.NODE_ENV === "development") {
return text;
}
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");

View File

@@ -5,8 +5,6 @@ interface Params {
args: string[];
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
stdin?: string;
timeout?: number;
onStdout?: (data: string) => void;
onStderr?: (error: string) => void;
onError?: (error: Error) => Promise<void> | void;
@@ -21,26 +19,17 @@ type SpawnResult = {
};
export const safeSpawn = (params: Params) => {
const { command, args, env = {}, signal, stdin, timeout, ...callbacks } = params;
const { command, args, env = {}, signal, ...callbacks } = params;
return new Promise<SpawnResult>((resolve, reject) => {
return new Promise<SpawnResult>((resolve) => {
let stdoutData = "";
let stderrData = "";
let timeoutId: NodeJS.Timeout | undefined;
const child = spawn(command, args, {
env: { ...process.env, ...env },
signal: signal,
});
// Handle timeout if specified
if (timeout) {
timeoutId = setTimeout(() => {
child.kill("SIGTERM");
reject(new Error(`Command timed out after ${timeout}ms`));
}, timeout);
}
child.stdout.on("data", (data) => {
if (callbacks.onStdout) {
callbacks.onStdout(data.toString());
@@ -58,7 +47,6 @@ export const safeSpawn = (params: Params) => {
});
child.on("error", async (error) => {
if (timeoutId) clearTimeout(timeoutId);
if (callbacks.onError) {
await callbacks.onError(error);
}
@@ -74,7 +62,6 @@ export const safeSpawn = (params: Params) => {
});
child.on("close", async (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (callbacks.onClose) {
await callbacks.onClose(code);
}
@@ -82,15 +69,11 @@ export const safeSpawn = (params: Params) => {
await callbacks.finally();
}
if (code !== 0 && code !== null) {
reject(new Error(`Command failed with exit code ${code}: ${stderrData || stdoutData}`));
} else {
resolve({
exitCode: code === null ? -1 : code,
stdout: stdoutData,
stderr: stderrData,
});
}
});
});
};

View File

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

View File

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

View File

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