mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
11 Commits
altendorfm
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70e4c782ff | ||
|
|
c726c6fc72 | ||
|
|
4d48d7be58 | ||
|
|
df6b70c96f | ||
|
|
94423bd0a5 | ||
|
|
ed2a625fa7 | ||
|
|
a3e027694a | ||
|
|
0d36484c04 | ||
|
|
67b1accbd0 | ||
|
|
98924ea59d | ||
|
|
e5435969be |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -74,6 +74,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -2,11 +2,8 @@ ARG BUN_VERSION="1.3.1"
|
|||||||
|
|
||||||
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
FROM oven/bun:${BUN_VERSION}-alpine AS base
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache davfs2=1.6.1-r2
|
||||||
davfs2=1.6.1-r2 \
|
|
||||||
mariadb-client \
|
|
||||||
mysql-client \
|
|
||||||
postgresql-client
|
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# DEPENDENCIES
|
# DEPENDENCIES
|
||||||
@@ -62,6 +59,8 @@ CMD ["bun", "run", "dev"]
|
|||||||
# ------------------------------
|
# ------------------------------
|
||||||
FROM oven/bun:${BUN_VERSION} AS builder
|
FROM oven/bun:${BUN_VERSION} AS builder
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY ./package.json ./bun.lock ./
|
COPY ./package.json ./bun.lock ./
|
||||||
@@ -69,6 +68,9 @@ RUN bun install --frozen-lockfile
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN touch .env
|
||||||
|
RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env
|
||||||
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -36,7 +36,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
image: ghcr.io/nicotsx/ironmount:v0.10
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -46,9 +46,13 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/ironmount:/var/lib/ironmount
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Do not try to change the location of the bind mount `/var/lib/ironmount` on your host or store it on a network share. You will likely face permission issues and strong performance degradation.
|
||||||
|
|
||||||
Then, run the following command to start Ironmount:
|
Then, run the following command to start Ironmount:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -68,7 +72,7 @@ If you want to track a local directory on the same server where Ironmount is run
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
image: ghcr.io/nicotsx/ironmount:v0.10
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -78,6 +82,7 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/ironmount:/var/lib/ironmount
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
+ - /path/to/your/directory:/mydata
|
+ - /path/to/your/directory:/mydata
|
||||||
```
|
```
|
||||||
@@ -133,7 +138,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
image: ghcr.io/nicotsx/ironmount:v0.10
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -143,6 +148,7 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
|
|||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/ironmount:/var/lib/ironmount
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
+ - ~/.config/rclone:/root/.config/rclone
|
+ - ~/.config/rclone:/root/.config/rclone
|
||||||
```
|
```
|
||||||
@@ -189,7 +195,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
image: ghcr.io/nicotsx/ironmount:v0.10
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -197,6 +203,7 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- - /var/lib/ironmount:/var/lib/ironmount
|
- - /var/lib/ironmount:/var/lib/ironmount
|
||||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||||
```
|
```
|
||||||
@@ -217,7 +224,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
|
|||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.9
|
image: ghcr.io/nicotsx/ironmount:v0.10
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -227,6 +234,7 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- - /var/lib/ironmount:/var/lib/ironmount
|
- - /var/lib/ironmount:/var/lib/ironmount
|
||||||
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
+ - /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||||
+ - /run/docker/plugins:/run/docker/plugins
|
+ - /run/docker/plugins:/run/docker/plugins
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { client } from '../client.gen';
|
import { client } from '../client.gen';
|
||||||
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
|
import { browseFilesystem, changePassword, createBackupSchedule, createRepository, createVolume, deleteBackupSchedule, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getRepository, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, unmountVolume, updateBackupSchedule, updateVolume } from '../sdk.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetRepositoryData, GetRepositoryResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user
|
* Register a new user
|
||||||
@@ -460,6 +460,23 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => que
|
|||||||
queryKey: listSnapshotsQueryKey(options)
|
queryKey: listSnapshotsQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific snapshot from a repository
|
||||||
|
*/
|
||||||
|
export const deleteSnapshotMutation = (options?: Partial<Options<DeleteSnapshotData>>): UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await deleteSnapshot({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options);
|
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetRepositoryData, GetRepositoryResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
@@ -286,6 +286,16 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(options: Opt
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific snapshot from a repository
|
||||||
|
*/
|
||||||
|
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get details of a specific snapshot
|
* Get details of a specific snapshot
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -157,22 +157,6 @@ export type ListVolumesResponses = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -180,15 +164,6 @@ export type ListVolumesResponses = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -215,7 +190,7 @@ export type ListVolumesResponses = {
|
|||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -228,22 +203,6 @@ export type CreateVolumeData = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -251,15 +210,6 @@ export type CreateVolumeData = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -297,22 +247,6 @@ export type CreateVolumeResponses = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -320,15 +254,6 @@ export type CreateVolumeResponses = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -355,7 +280,7 @@ export type CreateVolumeResponses = {
|
|||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -368,22 +293,6 @@ export type TestConnectionData = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -391,15 +300,6 @@ export type TestConnectionData = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -490,22 +390,6 @@ export type GetVolumeResponses = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -513,15 +397,6 @@ export type GetVolumeResponses = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -548,7 +423,7 @@ export type GetVolumeResponses = {
|
|||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -563,22 +438,6 @@ export type UpdateVolumeData = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -586,15 +445,6 @@ export type UpdateVolumeData = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -640,22 +490,6 @@ export type UpdateVolumeResponses = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -663,15 +497,6 @@ export type UpdateVolumeResponses = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -698,7 +523,7 @@ export type UpdateVolumeResponses = {
|
|||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -916,12 +741,21 @@ export type ListRepositoriesResponses = {
|
|||||||
name: string;
|
name: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
path?: string;
|
||||||
} | {
|
} | {
|
||||||
backend: 'rclone';
|
backend: 'rclone';
|
||||||
path: string;
|
path: string;
|
||||||
remote: string;
|
remote: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'rest';
|
||||||
|
url: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
password?: string;
|
||||||
|
path?: string;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -929,7 +763,7 @@ export type ListRepositoriesResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -974,12 +808,21 @@ export type CreateRepositoryData = {
|
|||||||
name: string;
|
name: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
path?: string;
|
||||||
} | {
|
} | {
|
||||||
backend: 'rclone';
|
backend: 'rclone';
|
||||||
path: string;
|
path: string;
|
||||||
remote: string;
|
remote: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'rest';
|
||||||
|
url: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
password?: string;
|
||||||
|
path?: string;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
name: string;
|
name: string;
|
||||||
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
|
||||||
@@ -1094,12 +937,21 @@ export type GetRepositoryResponses = {
|
|||||||
name: string;
|
name: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
path?: string;
|
||||||
} | {
|
} | {
|
||||||
backend: 'rclone';
|
backend: 'rclone';
|
||||||
path: string;
|
path: string;
|
||||||
remote: string;
|
remote: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'rest';
|
||||||
|
url: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
password?: string;
|
||||||
|
path?: string;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1107,7 +959,7 @@ export type GetRepositoryResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1140,6 +992,27 @@ export type ListSnapshotsResponses = {
|
|||||||
|
|
||||||
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
|
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
|
||||||
|
|
||||||
|
export type DeleteSnapshotData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
snapshotId: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteSnapshotResponses = {
|
||||||
|
/**
|
||||||
|
* Snapshot deleted successfully
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapshotResponses];
|
||||||
|
|
||||||
export type GetSnapshotDetailsData = {
|
export type GetSnapshotDetailsData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
@@ -1320,12 +1193,21 @@ export type ListBackupSchedulesResponses = {
|
|||||||
name: string;
|
name: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
path?: string;
|
||||||
} | {
|
} | {
|
||||||
backend: 'rclone';
|
backend: 'rclone';
|
||||||
path: string;
|
path: string;
|
||||||
remote: string;
|
remote: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'rest';
|
||||||
|
url: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
password?: string;
|
||||||
|
path?: string;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1333,7 +1215,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
@@ -1353,22 +1235,6 @@ export type ListBackupSchedulesResponses = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -1376,15 +1242,6 @@ export type ListBackupSchedulesResponses = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -1411,7 +1268,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
volumeId: number;
|
volumeId: number;
|
||||||
@@ -1558,12 +1415,21 @@ export type GetBackupScheduleResponses = {
|
|||||||
name: string;
|
name: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
path?: string;
|
||||||
} | {
|
} | {
|
||||||
backend: 'rclone';
|
backend: 'rclone';
|
||||||
path: string;
|
path: string;
|
||||||
remote: string;
|
remote: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'rest';
|
||||||
|
url: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
password?: string;
|
||||||
|
path?: string;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1571,7 +1437,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
@@ -1591,22 +1457,6 @@ export type GetBackupScheduleResponses = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -1614,15 +1464,6 @@ export type GetBackupScheduleResponses = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -1649,7 +1490,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
volumeId: number;
|
volumeId: number;
|
||||||
@@ -1777,12 +1618,21 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
name: string;
|
name: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
path?: string;
|
||||||
} | {
|
} | {
|
||||||
backend: 'rclone';
|
backend: 'rclone';
|
||||||
path: string;
|
path: string;
|
||||||
remote: string;
|
remote: string;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
|
} | {
|
||||||
|
backend: 'rest';
|
||||||
|
url: string;
|
||||||
|
customPassword?: string;
|
||||||
|
isExistingRepository?: boolean;
|
||||||
|
password?: string;
|
||||||
|
path?: string;
|
||||||
|
username?: string;
|
||||||
};
|
};
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1790,7 +1640,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'healthy' | 'unknown' | null;
|
status: 'error' | 'healthy' | 'unknown' | null;
|
||||||
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
|
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
@@ -1810,22 +1660,6 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
backend: 'directory';
|
backend: 'directory';
|
||||||
path: string;
|
path: string;
|
||||||
readOnly?: false;
|
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';
|
backend: 'nfs';
|
||||||
exportPath: string;
|
exportPath: string;
|
||||||
@@ -1833,15 +1667,6 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
version: '3' | '4' | '4.1';
|
version: '3' | '4' | '4.1';
|
||||||
port?: number;
|
port?: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
} | {
|
|
||||||
backend: 'postgres';
|
|
||||||
database: string;
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
dumpFormat?: 'custom' | 'directory' | 'plain';
|
|
||||||
port?: number;
|
|
||||||
dumpOptions?: Array<string>;
|
|
||||||
} | {
|
} | {
|
||||||
backend: 'smb';
|
backend: 'smb';
|
||||||
password: string;
|
password: string;
|
||||||
@@ -1868,7 +1693,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
lastHealthCheck: number;
|
lastHealthCheck: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'error' | 'mounted' | 'unmounted';
|
status: 'error' | 'mounted' | 'unmounted';
|
||||||
type: 'directory' | 'mariadb' | 'mysql' | 'nfs' | 'postgres' | 'smb' | 'webdav';
|
type: 'directory' | 'nfs' | 'smb' | 'webdav';
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
volumeId: number;
|
volumeId: number;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, NavLink } from "react-router";
|
|||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from "~/client/components/ui/sidebar";
|
} from "~/client/components/ui/sidebar";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import { APP_VERSION } from "~/client/lib/version";
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -85,6 +87,15 @@ export function AppSidebar() {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarFooter className="p-4 border-r border-t border-border/50">
|
||||||
|
<div
|
||||||
|
className={cn("text-xs text-muted-foreground transition-all duration-200", {
|
||||||
|
"opacity-0 w-0 overflow-hidden": state === "collapsed",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{APP_VERSION}
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,23 @@ import { Input } from "./ui/input";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Alert, AlertDescription } from "./ui/alert";
|
import { Alert, AlertDescription } from "./ui/alert";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink, AlertTriangle } from "lucide-react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
||||||
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
|
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
|
||||||
import { Checkbox } from "./ui/checkbox";
|
import { Checkbox } from "./ui/checkbox";
|
||||||
|
import { DirectoryBrowser } from "./directory-browser";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "./ui/alert-dialog";
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
@@ -41,6 +52,7 @@ const defaultValuesForType = {
|
|||||||
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
|
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
|
||||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||||
|
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateRepositoryForm = ({
|
export const CreateRepositoryForm = ({
|
||||||
@@ -66,6 +78,8 @@ export const CreateRepositoryForm = ({
|
|||||||
const watchedIsExistingRepository = watch("isExistingRepository");
|
const watchedIsExistingRepository = watch("isExistingRepository");
|
||||||
|
|
||||||
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||||
|
const [showPathBrowser, setShowPathBrowser] = useState(false);
|
||||||
|
const [showPathWarning, setShowPathWarning] = useState(false);
|
||||||
|
|
||||||
const { capabilities } = useSystemInfo();
|
const { capabilities } = useSystemInfo();
|
||||||
|
|
||||||
@@ -126,6 +140,7 @@ export const CreateRepositoryForm = ({
|
|||||||
<SelectItem value="r2">Cloudflare R2</SelectItem>
|
<SelectItem value="r2">Cloudflare R2</SelectItem>
|
||||||
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
|
||||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||||
|
<SelectItem value="rest">REST Server</SelectItem>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
<SelectItem disabled={!capabilities.rclone} value="rclone">
|
||||||
@@ -245,6 +260,87 @@ export const CreateRepositoryForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{watchedBackend === "local" && (
|
||||||
|
<>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Directory</FormLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||||
|
{form.watch("path") || "/var/lib/ironmount/repositories"}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowPathWarning(true)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
The directory where the repository will be stored.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||||
|
Important: Host Mount Required
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="space-y-3">
|
||||||
|
<p>
|
||||||
|
When selecting a custom path, ensure it is mounted from the host machine into the
|
||||||
|
container.
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
If the path is not a host mount, you will lose your repository data when the container
|
||||||
|
restarts.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The default path <code className="bg-muted px-1 rounded">/var/lib/ironmount/repositories</code> is
|
||||||
|
already mounted from the host and is safe to use.
|
||||||
|
</p>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
setShowPathBrowser(true);
|
||||||
|
setShowPathWarning(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I Understand, Continue
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
|
||||||
|
<AlertDialogContent className="max-w-2xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Choose a directory from the filesystem to store the repository.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<DirectoryBrowser
|
||||||
|
onSelectPath={(path) => form.setValue("path", path)}
|
||||||
|
selectedPath={form.watch("path") || "/var/lib/ironmount/repositories"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>Done</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchedBackend === "s3" && (
|
{watchedBackend === "s3" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -546,6 +642,67 @@ export const CreateRepositoryForm = ({
|
|||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{watchedBackend === "rest" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>REST Server URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="http://192.168.1.30:8000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>URL of the REST server.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Repository Path (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-backup-repo" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="username" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Username for REST server authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Password for REST server authentication.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === "update" && (
|
{mode === "update" && (
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -6,31 +6,13 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { cn, slugify } from "~/client/lib/utils";
|
import { cn, slugify } from "~/client/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
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 { volumeConfigSchema } from "~/schemas/volumes";
|
||||||
import { testConnectionMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "../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"];
|
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
@@ -53,9 +35,6 @@ const defaultValuesForType = {
|
|||||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
||||||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
||||||
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
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) => {
|
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 handleTestConnection = async () => {
|
||||||
const formValues = getValues();
|
const formValues = getValues();
|
||||||
|
|
||||||
if (SUPPORTS_CONNECTION_TEST.includes(formValues.backend)) {
|
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
|
||||||
testBackendConnection.mutate({
|
testBackendConnection.mutate({
|
||||||
body: { config: formValues },
|
body: { config: formValues },
|
||||||
});
|
});
|
||||||
@@ -142,26 +121,15 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormLabel>Backend</FormLabel>
|
<FormLabel>Backend</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-[280px]">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a backend" />
|
<SelectValue placeholder="Select a backend" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="directory">Directory</SelectItem>
|
<SelectItem value="directory">Directory</SelectItem>
|
||||||
</SelectGroup>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Network Storage</SelectLabel>
|
|
||||||
<SelectItem value="nfs">NFS</SelectItem>
|
<SelectItem value="nfs">NFS</SelectItem>
|
||||||
<SelectItem value="smb">SMB</SelectItem>
|
<SelectItem value="smb">SMB</SelectItem>
|
||||||
<SelectItem value="webdav">WebDAV</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
||||||
@@ -239,7 +207,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Port</FormLabel>
|
<FormLabel>Port</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="2049" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="2049"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>NFS server port (default: 2049).</FormDescription>
|
<FormDescription>NFS server port (default: 2049).</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -364,7 +337,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Port</FormLabel>
|
<FormLabel>Port</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="80" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="80"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
|
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -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" && (
|
{watchedBackend !== "directory" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Database, HardDrive, Cloud } from "lucide-react";
|
import { Database, HardDrive, Cloud, Server } from "lucide-react";
|
||||||
import type { RepositoryBackend } from "~/schemas/restic";
|
import type { RepositoryBackend } from "~/schemas/restic";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,6 +14,8 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
|
|||||||
return <Cloud className={className} />;
|
return <Cloud className={className} />;
|
||||||
case "gcs":
|
case "gcs":
|
||||||
return <Cloud className={className} />;
|
return <Cloud className={className} />;
|
||||||
|
case "rest":
|
||||||
|
return <Server className={className} />;
|
||||||
default:
|
default:
|
||||||
return <Database className={className} />;
|
return <Database className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||||
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
import type { ListSnapshotsResponse } from "../api-client";
|
import type { ListSnapshotsResponse } from "../api-client";
|
||||||
|
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
||||||
type Snapshot = ListSnapshotsResponse[number];
|
type Snapshot = ListSnapshotsResponse[number];
|
||||||
|
|
||||||
@@ -15,12 +31,46 @@ type Props = {
|
|||||||
|
|
||||||
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const deleteSnapshot = useMutation({
|
||||||
|
...deleteSnapshotMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setSnapshotToDelete(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSnapshotToDelete(snapshotId);
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
if (snapshotToDelete) {
|
||||||
|
toast.promise(
|
||||||
|
deleteSnapshot.mutateAsync({
|
||||||
|
path: { name: repositoryName, snapshotId: snapshotToDelete },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Deleting snapshot...",
|
||||||
|
success: "Snapshot deleted successfully",
|
||||||
|
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRowClick = (snapshotId: string) => {
|
const handleRowClick = (snapshotId: string) => {
|
||||||
navigate(`/repositories/${repositoryName}/${snapshotId}`);
|
navigate(`/repositories/${repositoryName}/${snapshotId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="border-t">
|
<Table className="border-t">
|
||||||
<TableHeader className="bg-card-header">
|
<TableHeader className="bg-card-header">
|
||||||
@@ -30,6 +80,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
|||||||
<TableHead className="uppercase">Size</TableHead>
|
<TableHead className="uppercase">Size</TableHead>
|
||||||
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||||
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
|
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
|
||||||
|
<TableHead className="uppercase text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -86,10 +137,43 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
|
||||||
|
disabled={deleteSnapshot.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the snapshot and all its data from the
|
||||||
|
repository.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deleteSnapshot.isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete snapshot
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
import type { BackendType } from "~/schemas/volumes";
|
||||||
|
|
||||||
type VolumeIconProps = {
|
type VolumeIconProps = {
|
||||||
@@ -32,24 +32,6 @@ const getIconAndColor = (backend: BackendType) => {
|
|||||||
color: "text-green-600 dark:text-green-400",
|
color: "text-green-600 dark:text-green-400",
|
||||||
label: "WebDAV",
|
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:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: Folder,
|
icon: Folder,
|
||||||
|
|||||||
1
app/client/lib/version.ts
Normal file
1
app/client/lib/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
@@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware];
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Download Recovery Key" },
|
{ title: "Ironmount - Download Recovery Key" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Download your backup recovery key to ensure you can restore your data.",
|
content: "Download your backup recovery key to ensure you can restore your data.",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const clientMiddleware = [authMiddleware];
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Login" },
|
{ title: "Ironmount - Login" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Sign in to your Ironmount account.",
|
content: "Sign in to your Ironmount account.",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const clientMiddleware = [authMiddleware];
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Onboarding" },
|
{ title: "Ironmount - Onboarding" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Welcome to Ironmount. Create your admin account to get started.",
|
content: "Welcome to Ironmount. Create your admin account to get started.",
|
||||||
|
|||||||
@@ -254,8 +254,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Backup paths</CardTitle>
|
<CardTitle>Backup paths</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Select which folders or files to include in the backup. If no paths are selected, the entire volume will be
|
Select which folders or files to include in the backup. If no paths are selected, the entire volume will
|
||||||
backed up.
|
be backed up.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ interface Props {
|
|||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
volume?: Volume;
|
volume?: Volume;
|
||||||
|
onDeleteSnapshot?: (snapshotId: string) => void;
|
||||||
|
isDeletingSnapshot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
const { snapshot, repositoryName, volume } = props;
|
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||||
|
|
||||||
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
||||||
|
|
||||||
@@ -136,6 +138,7 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
<CardTitle>File Browser</CardTitle>
|
<CardTitle>File Browser</CardTitle>
|
||||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
{selectedPaths.size > 0 && (
|
{selectedPaths.size > 0 && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -160,6 +163,18 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{onDeleteSnapshot && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDeleteSnapshot(snapshot.short_id)}
|
||||||
|
disabled={isDeletingSnapshot}
|
||||||
|
loading={isDeletingSnapshot}
|
||||||
|
>
|
||||||
|
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { useQuery, useMutation } from "@tanstack/react-query";
|
|||||||
import { redirect, useNavigate } from "react-router";
|
import { redirect, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import {
|
import {
|
||||||
getBackupScheduleOptions,
|
getBackupScheduleOptions,
|
||||||
runBackupNowMutation,
|
runBackupNowMutation,
|
||||||
@@ -10,6 +20,7 @@ import {
|
|||||||
listSnapshotsOptions,
|
listSnapshotsOptions,
|
||||||
updateBackupScheduleMutation,
|
updateBackupScheduleMutation,
|
||||||
stopBackupMutation,
|
stopBackupMutation,
|
||||||
|
deleteSnapshotMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { getCronExpression } from "~/utils/utils";
|
import { getCronExpression } from "~/utils/utils";
|
||||||
@@ -29,7 +40,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Backup Job Details" },
|
{ title: "Ironmount - Backup Job Details" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "View and manage backup job configuration, schedule, and snapshots.",
|
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||||
@@ -50,6 +61,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const formId = useId();
|
const formId = useId();
|
||||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: schedule } = useQuery({
|
const { data: schedule } = useQuery({
|
||||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||||
@@ -110,6 +123,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteSnapshot = useMutation({
|
||||||
|
...deleteSnapshotMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setSnapshotToDelete(null);
|
||||||
|
if (selectedSnapshotId === snapshotToDelete) {
|
||||||
|
setSelectedSnapshotId(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = (formValues: BackupScheduleFormValues) => {
|
const handleSubmit = (formValues: BackupScheduleFormValues) => {
|
||||||
if (!schedule) return;
|
if (!schedule) return;
|
||||||
|
|
||||||
@@ -150,6 +174,26 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSnapshot = (snapshotId: string) => {
|
||||||
|
setSnapshotToDelete(snapshotId);
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
if (snapshotToDelete) {
|
||||||
|
toast.promise(
|
||||||
|
deleteSnapshot.mutateAsync({
|
||||||
|
path: { name: schedule.repository.name, snapshotId: snapshotToDelete },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Deleting snapshot...",
|
||||||
|
success: "Snapshot deleted successfully",
|
||||||
|
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -191,8 +235,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
snapshot={selectedSnapshot}
|
snapshot={selectedSnapshot}
|
||||||
repositoryName={schedule.repository.name}
|
repositoryName={schedule.repository.name}
|
||||||
volume={schedule.volume}
|
volume={schedule.volume}
|
||||||
|
onDeleteSnapshot={handleDeleteSnapshot}
|
||||||
|
isDeletingSnapshot={deleteSnapshot.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the snapshot and all its data from the
|
||||||
|
repository.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deleteSnapshot.isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete snapshot
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Backup Jobs" },
|
{ title: "Ironmount - Backup Jobs" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Create Backup Job" },
|
{ title: "Ironmount - Create Backup Job" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create a new automated backup job for your volumes.",
|
content: "Create a new automated backup job for your volumes.",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Create Repository" },
|
{ title: "Ironmount - Create Repository" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create a new backup repository with encryption and compression.",
|
content: "Create a new backup repository with encryption and compression.",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Repositories" },
|
{ title: "Ironmount - Repositories" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Manage your backup repositories with encryption and compression.",
|
content: "Manage your backup repositories with encryption and compression.",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: params.name },
|
{ title: `Ironmount - ${params.name}` },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "View repository configuration, status, and snapshots.",
|
content: "View repository configuration, status, and snapshots.",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: `Snapshot ${params.snapshotId}` },
|
{ title: `Ironmount - Snapshot ${params.snapshotId}` },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Browse and restore files from a backup snapshot.",
|
content: "Browse and restore files from a backup snapshot.",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Settings" },
|
{ title: "Ironmount - Settings" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Manage your account settings and preferences.",
|
content: "Manage your account settings and preferences.",
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useId } from "react";
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
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 { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import type { Route } from "./+types/create-volume";
|
import type { Route } from "./+types/create-volume";
|
||||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||||
import { CreateVolumeForm, type FormValues } from "../components/create-volume-form";
|
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
|
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
|
||||||
@@ -17,7 +17,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Create Volume" },
|
{ title: "Ironmount - Create Volume" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create a new storage volume with automatic mounting and health checks.",
|
content: "Create a new storage volume with automatic mounting and health checks.",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: params.name },
|
{ title: `Ironmount - ${params.name}` },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "View and manage volume details, configuration, and files.",
|
content: "View and manage volume details, configuration, and files.",
|
||||||
@@ -119,8 +119,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
const { volume, statfs } = data;
|
const { volume, statfs } = data;
|
||||||
const dockerAvailable = capabilities.docker;
|
const dockerAvailable = capabilities.docker;
|
||||||
|
|
||||||
const isDatabaseVolume = ["mariadb", "mysql", "postgres"].includes(volume.config.backend);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
<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">
|
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||||
<TabsList className="mb-2">
|
<TabsList className="mb-2">
|
||||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||||
<TabsTrigger disabled={isDatabaseVolume} value="files">
|
<TabsTrigger value="files">Files</TabsTrigger>
|
||||||
Files
|
|
||||||
</TabsTrigger>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<TabsTrigger disabled={!dockerAvailable} value="docker">
|
<TabsTrigger disabled={!dockerAvailable} value="docker">
|
||||||
@@ -171,11 +167,9 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
<TabsContent value="info">
|
<TabsContent value="info">
|
||||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{!isDatabaseVolume && (
|
|
||||||
<TabsContent value="files">
|
<TabsContent value="files">
|
||||||
<FilesTabContent volume={volume} />
|
<FilesTabContent volume={volume} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
{dockerAvailable && (
|
{dockerAvailable && (
|
||||||
<TabsContent value="docker">
|
<TabsContent value="docker">
|
||||||
<DockerTabContent volume={volume} />
|
<DockerTabContent volume={volume} />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const handle = {
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Volumes" },
|
{ title: "Ironmount - Volumes" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||||
@@ -109,10 +109,6 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
|
|||||||
<SelectItem value="directory">Directory</SelectItem>
|
<SelectItem value="directory">Directory</SelectItem>
|
||||||
<SelectItem value="nfs">NFS</SelectItem>
|
<SelectItem value="nfs">NFS</SelectItem>
|
||||||
<SelectItem value="smb">SMB</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{(searchQuery || statusFilter || backendFilter) && (
|
{(searchQuery || statusFilter || backendFilter) && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -16,7 +17,6 @@ import type { StatFs, Volume } from "~/client/lib/types";
|
|||||||
import { HealthchecksCard } from "../components/healthchecks-card";
|
import { HealthchecksCard } from "../components/healthchecks-card";
|
||||||
import { StorageChart } from "../components/storage-chart";
|
import { StorageChart } from "../components/storage-chart";
|
||||||
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateVolumeForm, type FormValues } from "../components/create-volume-form";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const REPOSITORY_BACKENDS = {
|
|||||||
gcs: "gcs",
|
gcs: "gcs",
|
||||||
azure: "azure",
|
azure: "azure",
|
||||||
rclone: "rclone",
|
rclone: "rclone",
|
||||||
|
rest: "rest",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||||
@@ -36,6 +37,7 @@ export const r2RepositoryConfigSchema = type({
|
|||||||
export const localRepositoryConfigSchema = type({
|
export const localRepositoryConfigSchema = type({
|
||||||
backend: "'local'",
|
backend: "'local'",
|
||||||
name: "string",
|
name: "string",
|
||||||
|
path: "string?",
|
||||||
}).and(baseRepositoryConfigSchema);
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const gcsRepositoryConfigSchema = type({
|
export const gcsRepositoryConfigSchema = type({
|
||||||
@@ -59,12 +61,21 @@ export const rcloneRepositoryConfigSchema = type({
|
|||||||
path: "string",
|
path: "string",
|
||||||
}).and(baseRepositoryConfigSchema);
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const restRepositoryConfigSchema = type({
|
||||||
|
backend: "'rest'",
|
||||||
|
url: "string",
|
||||||
|
username: "string?",
|
||||||
|
password: "string?",
|
||||||
|
path: "string?",
|
||||||
|
}).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||||
.or(r2RepositoryConfigSchema)
|
.or(r2RepositoryConfigSchema)
|
||||||
.or(localRepositoryConfigSchema)
|
.or(localRepositoryConfigSchema)
|
||||||
.or(gcsRepositoryConfigSchema)
|
.or(gcsRepositoryConfigSchema)
|
||||||
.or(azureRepositoryConfigSchema)
|
.or(azureRepositoryConfigSchema)
|
||||||
.or(rcloneRepositoryConfigSchema);
|
.or(rcloneRepositoryConfigSchema)
|
||||||
|
.or(restRepositoryConfigSchema);
|
||||||
|
|
||||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ export const BACKEND_TYPES = {
|
|||||||
smb: "smb",
|
smb: "smb",
|
||||||
directory: "directory",
|
directory: "directory",
|
||||||
webdav: "webdav",
|
webdav: "webdav",
|
||||||
mariadb: "mariadb",
|
|
||||||
mysql: "mysql",
|
|
||||||
postgres: "postgres",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||||
@@ -50,47 +47,7 @@ export const webdavConfigSchema = type({
|
|||||||
ssl: "boolean?",
|
ssl: "boolean?",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mariadbConfigSchema = type({
|
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema);
|
||||||
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 type BackendConfig = typeof volumeConfigSchema.infer;
|
export type BackendConfig = typeof volumeConfigSchema.infer;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { volumeService } from "../modules/volumes/volume.service";
|
import { volumeService } from "../modules/volumes/volume.service";
|
||||||
import { readMountInfo } from "../utils/mountinfo";
|
import { readMountInfo } from "../utils/mountinfo";
|
||||||
import { createVolumeBackend } from "../modules/backends/backend";
|
import { getVolumePath } from "../modules/volumes/helpers";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { executeUnmount } from "../modules/backends/utils/backend-utils";
|
import { executeUnmount } from "../modules/backends/utils/backend-utils";
|
||||||
import { toMessage } from "../utils/errors";
|
import { toMessage } from "../utils/errors";
|
||||||
@@ -16,11 +16,7 @@ export class CleanupDanglingMountsJob extends Job {
|
|||||||
|
|
||||||
for (const mount of allSystemMounts) {
|
for (const mount of allSystemMounts) {
|
||||||
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
|
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
|
||||||
const matchingVolume = allVolumes.find((v) => {
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
|
||||||
const backend = createVolumeBackend(v);
|
|
||||||
return backend.getVolumePath() === mount.mountPoint;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!matchingVolume) {
|
if (!matchingVolume) {
|
||||||
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
||||||
await executeUnmount(mount.mountPoint).catch((err) => {
|
await executeUnmount(mount.mountPoint).catch((err) => {
|
||||||
@@ -40,10 +36,7 @@ export class CleanupDanglingMountsJob extends Job {
|
|||||||
|
|
||||||
for (const dir of allIronmountDirs) {
|
for (const dir of allIronmountDirs) {
|
||||||
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
|
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
|
||||||
const matchingVolume = allVolumes.find((v) => {
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === volumePath);
|
||||||
const backend = createVolumeBackend(v);
|
|
||||||
return backend.getVolumePath() === volumePath;
|
|
||||||
});
|
|
||||||
if (!matchingVolume) {
|
if (!matchingVolume) {
|
||||||
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
|
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
|
||||||
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
|
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { BackendStatus } from "~/schemas/volumes";
|
import type { BackendStatus } from "~/schemas/volumes";
|
||||||
import type { Volume } from "../../db/schema";
|
import type { Volume } from "../../db/schema";
|
||||||
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
import { makeDirectoryBackend } from "./directory/directory-backend";
|
import { makeDirectoryBackend } from "./directory/directory-backend";
|
||||||
import { makeNfsBackend } from "./nfs/nfs-backend";
|
import { makeNfsBackend } from "./nfs/nfs-backend";
|
||||||
import { makeSmbBackend } from "./smb/smb-backend";
|
import { makeSmbBackend } from "./smb/smb-backend";
|
||||||
import { makeWebdavBackend } from "./webdav/webdav-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 = {
|
type OperationResult = {
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -17,35 +15,23 @@ export type VolumeBackend = {
|
|||||||
mount: () => Promise<OperationResult>;
|
mount: () => Promise<OperationResult>;
|
||||||
unmount: () => Promise<OperationResult>;
|
unmount: () => Promise<OperationResult>;
|
||||||
checkHealth: () => Promise<OperationResult>;
|
checkHealth: () => Promise<OperationResult>;
|
||||||
getVolumePath: () => string;
|
|
||||||
getBackupPath: () => Promise<string>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
||||||
|
const path = getVolumePath(volume);
|
||||||
|
|
||||||
switch (volume.config.backend) {
|
switch (volume.config.backend) {
|
||||||
case "nfs": {
|
case "nfs": {
|
||||||
return makeNfsBackend(volume.config, volume.name);
|
return makeNfsBackend(volume.config, path);
|
||||||
}
|
}
|
||||||
case "smb": {
|
case "smb": {
|
||||||
return makeSmbBackend(volume.config, volume.name);
|
return makeSmbBackend(volume.config, path);
|
||||||
}
|
}
|
||||||
case "directory": {
|
case "directory": {
|
||||||
return makeDirectoryBackend(volume.config, volume.name);
|
return makeDirectoryBackend(volume.config, path);
|
||||||
}
|
}
|
||||||
case "webdav": {
|
case "webdav": {
|
||||||
return makeWebdavBackend(volume.config, volume.name);
|
return makeWebdavBackend(volume.config, path);
|
||||||
}
|
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 => ({
|
export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({
|
||||||
mount: () => mount(config, volumePath),
|
mount: () => mount(config, volumePath),
|
||||||
unmount,
|
unmount,
|
||||||
checkHealth: () => checkHealth(config),
|
checkHealth: () => checkHealth(config),
|
||||||
getVolumePath: () => getVolumePath(config),
|
|
||||||
getBackupPath: async () => getVolumePath(config),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
|
||||||
});
|
|
||||||
@@ -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),
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
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 { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
@@ -9,8 +9,7 @@ import type { VolumeBackend } from "../backend";
|
|||||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||||
|
|
||||||
const mount = async (config: BackendConfig, name: string) => {
|
const mount = async (config: BackendConfig, path: string) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
logger.debug(`Mounting volume ${path}...`);
|
logger.debug(`Mounting volume ${path}...`);
|
||||||
|
|
||||||
if (config.backend !== "nfs") {
|
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." };
|
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") {
|
if (status === "mounted") {
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
|
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
|
||||||
await unmount(name);
|
await unmount(path);
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
await fs.mkdir(path, { recursive: true });
|
await fs.mkdir(path, { recursive: true });
|
||||||
@@ -58,9 +57,7 @@ const mount = async (config: BackendConfig, name: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unmount = async (name: string) => {
|
const unmount = async (path: string) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
|
|
||||||
if (os.platform() !== "linux") {
|
if (os.platform() !== "linux") {
|
||||||
logger.error("NFS unmounting is only supported on Linux hosts.");
|
logger.error("NFS unmounting is only supported on Linux hosts.");
|
||||||
return { status: BACKEND_STATUS.error, 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 checkHealth = async (path: string, readOnly: boolean) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
logger.debug(`Checking health of NFS volume at ${path}...`);
|
logger.debug(`Checking health of NFS volume at ${path}...`);
|
||||||
await fs.access(path);
|
await fs.access(path);
|
||||||
@@ -119,14 +114,8 @@ const checkHealth = async (name: string, readOnly: boolean) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVolumePath = (name: string) => {
|
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
mount: () => mount(config, path),
|
||||||
};
|
unmount: () => unmount(path),
|
||||||
|
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
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 { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
@@ -9,8 +9,7 @@ import type { VolumeBackend } from "../backend";
|
|||||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||||
|
|
||||||
const mount = async (config: BackendConfig, name: string) => {
|
const mount = async (config: BackendConfig, path: string) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
logger.debug(`Mounting SMB volume ${path}...`);
|
logger.debug(`Mounting SMB volume ${path}...`);
|
||||||
|
|
||||||
if (config.backend !== "smb") {
|
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." };
|
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") {
|
if (status === "mounted") {
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
|
logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`);
|
||||||
await unmount(name);
|
await unmount(path);
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
await fs.mkdir(path, { recursive: true });
|
await fs.mkdir(path, { recursive: true });
|
||||||
@@ -71,9 +70,7 @@ const mount = async (config: BackendConfig, name: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unmount = async (name: string) => {
|
const unmount = async (path: string) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
|
|
||||||
if (os.platform() !== "linux") {
|
if (os.platform() !== "linux") {
|
||||||
logger.error("SMB unmounting is only supported on Linux hosts.");
|
logger.error("SMB unmounting is only supported on Linux hosts.");
|
||||||
return { status: BACKEND_STATUS.error, 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 checkHealth = async (path: string, readOnly: boolean) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
logger.debug(`Checking health of SMB volume at ${path}...`);
|
logger.debug(`Checking health of SMB volume at ${path}...`);
|
||||||
await fs.access(path);
|
await fs.access(path);
|
||||||
@@ -132,14 +127,8 @@ const checkHealth = async (name: string, readOnly: boolean) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVolumePath = (name: string) => {
|
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
mount: () => mount(config, path),
|
||||||
};
|
unmount: () => unmount(path),
|
||||||
|
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { execFile as execFileCb } from "node:child_process";
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import { promisify } from "node:util";
|
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 { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
@@ -13,8 +13,7 @@ import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
|||||||
|
|
||||||
const execFile = promisify(execFileCb);
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
const mount = async (config: BackendConfig, name: string) => {
|
const mount = async (config: BackendConfig, path: string) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
logger.debug(`Mounting WebDAV volume ${path}...`);
|
logger.debug(`Mounting WebDAV volume ${path}...`);
|
||||||
|
|
||||||
if (config.backend !== "webdav") {
|
if (config.backend !== "webdav") {
|
||||||
@@ -105,8 +104,7 @@ const mount = async (config: BackendConfig, name: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unmount = async (name: string) => {
|
const unmount = async (path: string) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
if (os.platform() !== "linux") {
|
if (os.platform() !== "linux") {
|
||||||
logger.error("WebDAV unmounting is only supported on Linux hosts.");
|
logger.error("WebDAV unmounting is only supported on Linux hosts.");
|
||||||
return { status: BACKEND_STATUS.error, 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 checkHealth = async (path: string, readOnly: boolean) => {
|
||||||
const path = getVolumePath(name);
|
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
logger.debug(`Checking health of WebDAV volume at ${path}...`);
|
logger.debug(`Checking health of WebDAV volume at ${path}...`);
|
||||||
await fs.access(path);
|
await fs.access(path);
|
||||||
@@ -165,14 +161,8 @@ const checkHealth = async (name: string, readOnly: boolean) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVolumePath = (name: string) => {
|
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
mount: () => mount(config, path),
|
||||||
};
|
unmount: () => unmount(path),
|
||||||
|
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { db } from "../../db/db";
|
|||||||
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { createVolumeBackend } from "../backends/backend";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
@@ -17,7 +17,7 @@ const calculateNextRun = (cronExpression: string): number => {
|
|||||||
try {
|
try {
|
||||||
const interval = CronExpressionParser.parse(cronExpression, {
|
const interval = CronExpressionParser.parse(cronExpression, {
|
||||||
currentDate: new Date(),
|
currentDate: new Date(),
|
||||||
tz: "UTC",
|
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
});
|
});
|
||||||
|
|
||||||
return interval.next().getTime();
|
return interval.next().getTime();
|
||||||
@@ -206,8 +206,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
runningBackups.set(scheduleId, abortController);
|
runningBackups.set(scheduleId, abortController);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backend = createVolumeBackend(volume);
|
const volumePath = getVolumePath(volume);
|
||||||
const backupPath = await backend.getBackupPath();
|
|
||||||
|
|
||||||
const backupOptions: {
|
const backupOptions: {
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
@@ -227,7 +226,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
backupOptions.include = schedule.includePatterns;
|
backupOptions.include = schedule.includePatterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
await restic.backup(repository.config, backupPath, {
|
await restic.backup(repository.config, volumePath, {
|
||||||
...backupOptions,
|
...backupOptions,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
serverEvents.emit("backup:progress", {
|
serverEvents.emit("backup:progress", {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { volumeService } from "../volumes/volume.service";
|
import { volumeService } from "../volumes/volume.service";
|
||||||
import { createVolumeBackend } from "../backends/backend";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
|
|
||||||
export const driverController = new Hono()
|
export const driverController = new Hono()
|
||||||
.post("/VolumeDriver.Capabilities", (c) => {
|
.post("/VolumeDriver.Capabilities", (c) => {
|
||||||
@@ -31,11 +31,9 @@ export const driverController = new Hono()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const volumeName = body.Name.replace(/^im-/, "");
|
const volumeName = body.Name.replace(/^im-/, "");
|
||||||
const { volume } = await volumeService.getVolume(volumeName);
|
|
||||||
const backend = createVolumeBackend(volume);
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: backend.getVolumePath(),
|
Mountpoint: getVolumePath(volumeName),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post("/VolumeDriver.Unmount", (c) => {
|
.post("/VolumeDriver.Unmount", (c) => {
|
||||||
@@ -51,10 +49,9 @@ export const driverController = new Hono()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||||
const backend = createVolumeBackend(volume);
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: backend.getVolumePath(),
|
Mountpoint: getVolumePath(volume),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post("/VolumeDriver.Get", async (c) => {
|
.post("/VolumeDriver.Get", async (c) => {
|
||||||
@@ -65,12 +62,11 @@ export const driverController = new Hono()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||||
const backend = createVolumeBackend(volume);
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Volume: {
|
Volume: {
|
||||||
Name: `im-${volume.name}`,
|
Name: `im-${volume.name}`,
|
||||||
Mountpoint: backend.getVolumePath(),
|
Mountpoint: getVolumePath(volume),
|
||||||
Status: {},
|
Status: {},
|
||||||
},
|
},
|
||||||
Err: "",
|
Err: "",
|
||||||
@@ -79,16 +75,11 @@ export const driverController = new Hono()
|
|||||||
.post("/VolumeDriver.List", async (c) => {
|
.post("/VolumeDriver.List", async (c) => {
|
||||||
const volumes = await volumeService.listVolumes();
|
const volumes = await volumeService.listVolumes();
|
||||||
|
|
||||||
let res = [];
|
const res = volumes.map((volume) => ({
|
||||||
for (const volume of volumes) {
|
|
||||||
const backend = createVolumeBackend(volume);
|
|
||||||
|
|
||||||
res.push({
|
|
||||||
Name: `im-${volume.name}`,
|
Name: `im-${volume.name}`,
|
||||||
Mountpoint: backend.getVolumePath(),
|
Mountpoint: getVolumePath(volume),
|
||||||
Status: {},
|
Status: {},
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Volumes: res,
|
Volumes: res,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createRepositoryBody,
|
createRepositoryBody,
|
||||||
createRepositoryDto,
|
createRepositoryDto,
|
||||||
deleteRepositoryDto,
|
deleteRepositoryDto,
|
||||||
|
deleteSnapshotDto,
|
||||||
doctorRepositoryDto,
|
doctorRepositoryDto,
|
||||||
getRepositoryDto,
|
getRepositoryDto,
|
||||||
getSnapshotDetailsDto,
|
getSnapshotDetailsDto,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
restoreSnapshotBody,
|
restoreSnapshotBody,
|
||||||
restoreSnapshotDto,
|
restoreSnapshotDto,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
|
type DeleteSnapshotDto,
|
||||||
type DoctorRepositoryDto,
|
type DoctorRepositoryDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
type GetSnapshotDetailsDto,
|
type GetSnapshotDetailsDto,
|
||||||
@@ -142,4 +144,11 @@ export const repositoriesController = new Hono()
|
|||||||
const result = await repositoriesService.doctorRepository(name);
|
const result = await repositoriesService.doctorRepository(name);
|
||||||
|
|
||||||
return c.json<DoctorRepositoryDto>(result, 200);
|
return c.json<DoctorRepositoryDto>(result, 200);
|
||||||
|
})
|
||||||
|
.delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
|
||||||
|
const { name, snapshotId } = c.req.param();
|
||||||
|
|
||||||
|
await repositoriesService.deleteSnapshot(name, snapshotId);
|
||||||
|
|
||||||
|
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||||||
case "azure":
|
case "azure":
|
||||||
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
|
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
|
||||||
break;
|
break;
|
||||||
|
case "rest":
|
||||||
|
if (config.username) {
|
||||||
|
encryptedConfig.username = await cryptoUtils.encrypt(config.username);
|
||||||
|
}
|
||||||
|
if (config.password) {
|
||||||
|
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return encryptedConfig as RepositoryConfig;
|
return encryptedConfig as RepositoryConfig;
|
||||||
@@ -327,6 +335,18 @@ const doctorRepository = async (name: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteSnapshot = async (name: string, snapshotId: string) => {
|
||||||
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: eq(repositoriesTable.name, name),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await restic.deleteSnapshot(repository.config, snapshotId);
|
||||||
|
};
|
||||||
|
|
||||||
export const repositoriesService = {
|
export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
@@ -338,4 +358,5 @@ export const repositoriesService = {
|
|||||||
getSnapshotDetails,
|
getSnapshotDetails,
|
||||||
checkHealth,
|
checkHealth,
|
||||||
doctorRepository,
|
doctorRepository,
|
||||||
|
deleteSnapshot,
|
||||||
};
|
};
|
||||||
|
|||||||
10
app/server/modules/volumes/helpers.ts
Normal file
10
app/server/modules/volumes/helpers.ts
Normal 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`;
|
||||||
|
};
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type BrowseFilesystemDto,
|
type BrowseFilesystemDto,
|
||||||
} from "./volume.dto";
|
} from "./volume.dto";
|
||||||
import { volumeService } from "./volume.service";
|
import { volumeService } from "./volume.service";
|
||||||
import { createVolumeBackend } from "../backends/backend";
|
import { getVolumePath } from "./helpers";
|
||||||
|
|
||||||
export const volumeController = new Hono()
|
export const volumeController = new Hono()
|
||||||
.get("/", listVolumesDto, async (c) => {
|
.get("/", listVolumesDto, async (c) => {
|
||||||
@@ -37,10 +37,9 @@ export const volumeController = new Hono()
|
|||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const res = await volumeService.createVolume(body.name, body.config);
|
const res = await volumeService.createVolume(body.name, body.config);
|
||||||
|
|
||||||
const backend = createVolumeBackend(res.volume);
|
|
||||||
const response = {
|
const response = {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
path: backend.getVolumePath(),
|
path: getVolumePath(res.volume),
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json<CreateVolumeDto>(response, 201);
|
return c.json<CreateVolumeDto>(response, 201);
|
||||||
@@ -61,11 +60,10 @@ export const volumeController = new Hono()
|
|||||||
const { name } = c.req.param();
|
const { name } = c.req.param();
|
||||||
const res = await volumeService.getVolume(name);
|
const res = await volumeService.getVolume(name);
|
||||||
|
|
||||||
const backend = createVolumeBackend(res.volume);
|
|
||||||
const response = {
|
const response = {
|
||||||
volume: {
|
volume: {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
path: backend.getVolumePath(),
|
path: getVolumePath(res.volume),
|
||||||
},
|
},
|
||||||
statfs: {
|
statfs: {
|
||||||
total: res.statfs.total ?? 0,
|
total: res.statfs.total ?? 0,
|
||||||
@@ -87,10 +85,9 @@ export const volumeController = new Hono()
|
|||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const res = await volumeService.updateVolume(name, body);
|
const res = await volumeService.updateVolume(name, body);
|
||||||
|
|
||||||
const backend = createVolumeBackend(res.volume);
|
|
||||||
const response = {
|
const response = {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
path: backend.getVolumePath(),
|
path: getVolumePath(res.volume),
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json<UpdateVolumeDto>(response, 200);
|
return c.json<UpdateVolumeDto>(response, 200);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
|||||||
import { withTimeout } from "../../utils/timeout";
|
import { withTimeout } from "../../utils/timeout";
|
||||||
import { createVolumeBackend } from "../backends/backend";
|
import { createVolumeBackend } from "../backends/backend";
|
||||||
import type { UpdateVolumeBody } from "./volume.dto";
|
import type { UpdateVolumeBody } from "./volume.dto";
|
||||||
|
import { getVolumePath } from "./helpers";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
import type { BackendConfig } from "~/schemas/volumes";
|
import type { BackendConfig } from "~/schemas/volumes";
|
||||||
@@ -128,9 +129,7 @@ const getVolume = async (name: string) => {
|
|||||||
|
|
||||||
let statfs: Partial<StatFs> = {};
|
let statfs: Partial<StatFs> = {};
|
||||||
if (volume.status === "mounted") {
|
if (volume.status === "mounted") {
|
||||||
const backend = createVolumeBackend(volume);
|
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
|
||||||
const volumePath = backend.getVolumePath();
|
|
||||||
statfs = await withTimeout(getStatFs(volumePath), 1000, "getStatFs").catch((error) => {
|
|
||||||
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
@@ -204,16 +203,7 @@ const testConnection = async (backendConfig: BackendConfig) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const backend = createVolumeBackend(mockVolume);
|
const backend = createVolumeBackend(mockVolume);
|
||||||
let error: string | null = null;
|
const { error } = await backend.mount();
|
||||||
const mount = await backend.mount();
|
|
||||||
if (mount.error) {
|
|
||||||
error = mount.error;
|
|
||||||
} else {
|
|
||||||
const health = await backend.checkHealth();
|
|
||||||
if (health.error) {
|
|
||||||
error = health.error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await backend.unmount();
|
await backend.unmount();
|
||||||
|
|
||||||
@@ -305,8 +295,8 @@ const listFiles = async (name: string, subPath?: string) => {
|
|||||||
throw new InternalServerError("Volume is not mounted");
|
throw new InternalServerError("Volume is not mounted");
|
||||||
}
|
}
|
||||||
|
|
||||||
const backend = createVolumeBackend(volume);
|
// For directory volumes, use the configured path directly
|
||||||
const volumePath = backend.getVolumePath();
|
const volumePath = getVolumePath(volume);
|
||||||
|
|
||||||
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
|
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const ensurePassfile = async () => {
|
|||||||
const buildRepoUrl = (config: RepositoryConfig): string => {
|
const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "local":
|
case "local":
|
||||||
return `${REPOSITORY_BASE}/${config.name}`;
|
return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`;
|
||||||
case "s3":
|
case "s3":
|
||||||
return `s3:${config.endpoint}/${config.bucket}`;
|
return `s3:${config.endpoint}/${config.bucket}`;
|
||||||
case "r2": {
|
case "r2": {
|
||||||
@@ -84,6 +84,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
|
|||||||
return `azure:${config.container}:/`;
|
return `azure:${config.container}:/`;
|
||||||
case "rclone":
|
case "rclone":
|
||||||
return `rclone:${config.remote}:${config.path}`;
|
return `rclone:${config.remote}:${config.path}`;
|
||||||
|
case "rest": {
|
||||||
|
const path = config.path ? `/${config.path}` : "";
|
||||||
|
return `rest:${config.url}${path}`;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
|
||||||
}
|
}
|
||||||
@@ -133,6 +137,15 @@ const buildEnv = async (config: RepositoryConfig) => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "rest": {
|
||||||
|
if (config.username) {
|
||||||
|
env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username);
|
||||||
|
}
|
||||||
|
if (config.password) {
|
||||||
|
env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
@@ -142,6 +155,9 @@ const init = async (config: RepositoryConfig) => {
|
|||||||
await ensurePassfile();
|
await ensurePassfile();
|
||||||
|
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
|
||||||
|
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
||||||
|
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
|
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();
|
||||||
@@ -441,6 +457,22 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
||||||
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
|
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||||
|
throw new Error(`Failed to delete snapshot: ${res.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
const lsNodeSchema = type({
|
const lsNodeSchema = type({
|
||||||
name: "string",
|
name: "string",
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -601,6 +633,7 @@ export const restic = {
|
|||||||
restore,
|
restore,
|
||||||
snapshots,
|
snapshots,
|
||||||
forget,
|
forget,
|
||||||
|
deleteSnapshot,
|
||||||
unlock,
|
unlock,
|
||||||
ls,
|
ls,
|
||||||
check,
|
check,
|
||||||
|
|||||||
@@ -3,10 +3,6 @@
|
|||||||
* This removes passwords and credentials from logs and error messages
|
* This removes passwords and credentials from logs and error messages
|
||||||
*/
|
*/
|
||||||
export const sanitizeSensitiveData = (text: string): string => {
|
export const sanitizeSensitiveData = (text: string): string => {
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
|
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
|
||||||
|
|
||||||
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
|
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ interface Params {
|
|||||||
args: string[];
|
args: string[];
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
stdin?: string;
|
|
||||||
timeout?: number;
|
|
||||||
onStdout?: (data: string) => void;
|
onStdout?: (data: string) => void;
|
||||||
onStderr?: (error: string) => void;
|
onStderr?: (error: string) => void;
|
||||||
onError?: (error: Error) => Promise<void> | void;
|
onError?: (error: Error) => Promise<void> | void;
|
||||||
@@ -21,26 +19,17 @@ type SpawnResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const safeSpawn = (params: Params) => {
|
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 stdoutData = "";
|
||||||
let stderrData = "";
|
let stderrData = "";
|
||||||
let timeoutId: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
const child = spawn(command, args, {
|
const child = spawn(command, args, {
|
||||||
env: { ...process.env, ...env },
|
env: { ...process.env, ...env },
|
||||||
signal: signal,
|
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) => {
|
child.stdout.on("data", (data) => {
|
||||||
if (callbacks.onStdout) {
|
if (callbacks.onStdout) {
|
||||||
callbacks.onStdout(data.toString());
|
callbacks.onStdout(data.toString());
|
||||||
@@ -58,7 +47,6 @@ export const safeSpawn = (params: Params) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on("error", async (error) => {
|
child.on("error", async (error) => {
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
if (callbacks.onError) {
|
if (callbacks.onError) {
|
||||||
await callbacks.onError(error);
|
await callbacks.onError(error);
|
||||||
}
|
}
|
||||||
@@ -74,7 +62,6 @@ export const safeSpawn = (params: Params) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on("close", async (code) => {
|
child.on("close", async (code) => {
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
if (callbacks.onClose) {
|
if (callbacks.onClose) {
|
||||||
await callbacks.onClose(code);
|
await callbacks.onClose(code);
|
||||||
}
|
}
|
||||||
@@ -82,15 +69,11 @@ export const safeSpawn = (params: Params) => {
|
|||||||
await callbacks.finally();
|
await callbacks.finally();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code !== 0 && code !== null) {
|
|
||||||
reject(new Error(`Command failed with exit code ${code}: ${stderrData || stdoutData}`));
|
|
||||||
} else {
|
|
||||||
resolve({
|
resolve({
|
||||||
exitCode: code === null ? -1 : code,
|
exitCode: code === null ? -1 : code,
|
||||||
stdout: stdoutData,
|
stdout: stdoutData,
|
||||||
stderr: stderrData,
|
stderr: stderrData,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
8
bun.lock
8
bun.lock
@@ -4,7 +4,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@ironmount/client",
|
"name": "@ironmount/client",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.6",
|
|
||||||
"@hono/standard-validator": "^0.1.5",
|
"@hono/standard-validator": "^0.1.5",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -23,8 +22,6 @@
|
|||||||
"@react-router/serve": "^7.9.3",
|
"@react-router/serve": "^7.9.3",
|
||||||
"@scalar/hono-api-reference": "^0.9.24",
|
"@scalar/hono-api-reference": "^0.9.24",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"arktype": "^2.1.26",
|
"arktype": "^2.1.26",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -60,6 +57,7 @@
|
|||||||
"@hey-api/openapi-ts": "^0.87.4",
|
"@hey-api/openapi-ts": "^0.87.4",
|
||||||
"@react-router/dev": "^7.9.3",
|
"@react-router/dev": "^7.9.3",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@types/bun": "^1.3.2",
|
"@types/bun": "^1.3.2",
|
||||||
"@types/dockerode": "^3.3.45",
|
"@types/dockerode": "^3.3.45",
|
||||||
"@types/node": "^24.6.2",
|
"@types/node": "^24.6.2",
|
||||||
@@ -482,10 +480,6 @@
|
|||||||
|
|
||||||
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="],
|
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="],
|
||||||
|
|
||||||
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
|
||||||
|
|
||||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||||
|
|
||||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
volumes:
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/ironmount:/var/lib/ironmount
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
|
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
@@ -37,6 +38,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
volumes:
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /var/lib/ironmount:/var/lib/ironmount:rshared
|
- /var/lib/ironmount:/var/lib/ironmount:rshared
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"studio": "drizzle-kit studio"
|
"studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.6",
|
|
||||||
"@hono/standard-validator": "^0.1.5",
|
"@hono/standard-validator": "^0.1.5",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
@@ -36,8 +35,6 @@
|
|||||||
"@react-router/serve": "^7.9.3",
|
"@react-router/serve": "^7.9.3",
|
||||||
"@scalar/hono-api-reference": "^0.9.24",
|
"@scalar/hono-api-reference": "^0.9.24",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"arktype": "^2.1.26",
|
"arktype": "^2.1.26",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -73,6 +70,7 @@
|
|||||||
"@hey-api/openapi-ts": "^0.87.4",
|
"@hey-api/openapi-ts": "^0.87.4",
|
||||||
"@react-router/dev": "^7.9.3",
|
"@react-router/dev": "^7.9.3",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
"@types/bun": "^1.3.2",
|
"@types/bun": "^1.3.2",
|
||||||
"@types/dockerode": "^3.3.45",
|
"@types/dockerode": "^3.3.45",
|
||||||
"@types/node": "^24.6.2",
|
"@types/node": "^24.6.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user