Compare commits
37 Commits
v0.4.0
...
v0.6.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd36397346 | ||
|
|
2ec8d4c1dd | ||
|
|
4b981bdcac | ||
|
|
5e908dc945 | ||
|
|
5f35cfd4c2 | ||
|
|
1152939373 | ||
|
|
94398f81bf | ||
|
|
db0d153610 | ||
|
|
5ff48f4d5d | ||
|
|
ffca433a43 | ||
|
|
4389029ba5 | ||
|
|
927db77f60 | ||
|
|
3e80850396 | ||
|
|
5f620b4c45 | ||
|
|
3abf8ab12d | ||
|
|
b5ba03da3d | ||
|
|
b289920720 | ||
|
|
ef5d95d347 | ||
|
|
a1ef34118c | ||
|
|
59433f3686 | ||
|
|
3debd80e15 | ||
|
|
0461ed63d8 | ||
|
|
1c4b1f34dd | ||
|
|
11dd6f46c8 | ||
|
|
fd3a527164 | ||
|
|
5b4b571581 | ||
|
|
4aeebea5b2 | ||
|
|
ba08c97681 | ||
|
|
42497be4b5 | ||
|
|
9d27bffc21 | ||
|
|
9ec765bd90 | ||
|
|
4dc239139f | ||
|
|
f5339d3708 | ||
|
|
418369c4ad | ||
|
|
195aea052e | ||
|
|
c32eb0831f | ||
|
|
885ae02b18 |
@@ -23,3 +23,4 @@
|
|||||||
!LICENSE
|
!LICENSE
|
||||||
!NOTICES.md
|
!NOTICES.md
|
||||||
!LICENSES/**
|
!LICENSES/**
|
||||||
|
|
||||||
|
|||||||
186
README.md
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<h1>Ironmount</h1>
|
<h1>Ironmount</h1>
|
||||||
<h3>Keep your volumes in check!<br />One interface to manage all your storage</h3>
|
<h3>Powerful backup automation for your remote storage<br />Encrypt, compress, and protect your data with ease</h3>
|
||||||
<a href="https://github.com/nicotsx/ironmount/blob/main/LICENSE">
|
<a href="https://github.com/nicotsx/ironmount/blob/main/LICENSE">
|
||||||
<img src="https://img.shields.io/github/license/nicotsx/ironmount" />
|
<img src="https://img.shields.io/github/license/nicotsx/ironmount" />
|
||||||
</a>
|
</a>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-details.png?raw=true" alt="Demo" />
|
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-details.png?raw=true" alt="Demo" />
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Volume details view with usage statistics and health check status
|
Backup management with scheduling and monitoring
|
||||||
</p>
|
</p>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
@@ -20,20 +20,14 @@
|
|||||||
|
|
||||||
## Intro
|
## Intro
|
||||||
|
|
||||||
Ironmount is an easy to use web interface to manage your remote storage and mount them as local volumes on your server. Docker as a first class citizen, Ironmount allows you to easily mount your remote storage directly into your containers with few lines of code.
|
Ironmount is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- ✅ Support for multiple protocols: NFS, SMB, WebDAV, Directory
|
- **Automated backups** with encryption, compression and retention policies powered by Restic
|
||||||
- 📡 Mount your remote storage as local folders
|
- **Flexible scheduling** For automated backup jobs with fine-grained retention policies
|
||||||
- 🐳 Docker integration: mount your remote storage directly into your containers via a docker volume syntax
|
- **End-to-end encryption** ensuring your data is always protected
|
||||||
- 🔍 Keep an eye on your mounts with health checks and automatic remounting on error
|
- **Multi-protocol support**: Backup from NFS, SMB, WebDAV, or local directories
|
||||||
- 📊 Monitor your mounts usage with detailed statistics and graphs
|
|
||||||
|
|
||||||
### Coming soon
|
|
||||||
|
|
||||||
- Automated backups with encryption and retention policies
|
|
||||||
- Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -42,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.4.0
|
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
privileged: true
|
||||||
@@ -51,16 +45,7 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/fuse:/dev/fuse
|
- /dev/fuse:/dev/fuse
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
|
||||||
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rslave
|
|
||||||
- /var/lib/repositories/:/var/lib/repositories
|
|
||||||
- /proc:/host/proc:ro
|
|
||||||
- ironmount_data:/data
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
ironmount_data:
|
|
||||||
driver: local
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, run the following command to start Ironmount:
|
Then, run the following command to start Ironmount:
|
||||||
@@ -71,9 +56,158 @@ docker compose up -d
|
|||||||
|
|
||||||
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
||||||
|
|
||||||
## Docker volume usage
|
## Adding your first volume
|
||||||
|
|
||||||

|
Ironmount supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor.
|
||||||
|
|
||||||
|
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
|
||||||
|
|
||||||
|
If you want to track a local directory on the same server where Ironmount is running, you'll first need to mount that directory into the Ironmount container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
services:
|
||||||
|
ironmount:
|
||||||
|
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
||||||
|
container_name: ironmount
|
||||||
|
restart: unless-stopped
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
|
ports:
|
||||||
|
- "4096:4096"
|
||||||
|
devices:
|
||||||
|
- /dev/fuse:/dev/fuse
|
||||||
|
volumes:
|
||||||
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
|
+ - /path/to/your/directory:/mydata
|
||||||
|
```
|
||||||
|
|
||||||
|
After updating the `docker-compose.yml` file, restart the Ironmount container to apply the changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, when adding a new volume in the Ironmount web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Creating a repository
|
||||||
|
|
||||||
|
A repository is where your backups will be securely stored encrypted. Ironmount currently supports S3-compatible storage backends and local directories for storing your backup repositories.
|
||||||
|
|
||||||
|
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
|
||||||
|
|
||||||
|
To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings. If you choose a local directory as the repository type, your backups will be stored at `/var/lib/ironmount/repositories/<repository-name>`.
|
||||||
|
|
||||||
|
## Your first backup job
|
||||||
|
|
||||||
|
Once you have added a volume and created a repository, you can create your first backup job. A backup job defines the schedule and parameters for backing up a specific volume to a designated repository.
|
||||||
|
|
||||||
|
When creating a backup job, you can specify the following settings:
|
||||||
|
- **Schedule**: Define how often the backup should run (e.g., daily, weekly)
|
||||||
|
- **Retention Policy**: Set rules for how long backups should be retained (e.g., keep daily backups for 7 days, weekly backups for 4 weeks)
|
||||||
|
- **Paths**: Specify which files or directories to include in the backup
|
||||||
|
|
||||||
|
After configuring the backup job, save it and Ironmount will automatically execute the backup according to the defined schedule.
|
||||||
|
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Restoring data
|
||||||
|
|
||||||
|
Ironmount allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Propagating mounts to host
|
||||||
|
|
||||||
|
Ironmount is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
|
||||||
|
|
||||||
|
In order to enable this feature, you need to run Ironmount with privileged mode and mount /proc from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
services:
|
||||||
|
ironmount:
|
||||||
|
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
||||||
|
container_name: ironmount
|
||||||
|
restart: unless-stopped
|
||||||
|
- cap_add:
|
||||||
|
- - SYS_ADMIN
|
||||||
|
+ privileged: true
|
||||||
|
ports:
|
||||||
|
- "4096:4096"
|
||||||
|
devices:
|
||||||
|
- /dev/fuse:/dev/fuse
|
||||||
|
volumes:
|
||||||
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
|
+ - /proc:/host/proc
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Ironmount container to apply the changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker plugin
|
||||||
|
|
||||||
|
Ironmount can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
|
||||||
|
|
||||||
|
In order to enable this feature, you need to run Ironmount with privileged mode and mount several items from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
services:
|
||||||
|
ironmount:
|
||||||
|
image: ghcr.io/nicotsx/ironmount:v0.5.0
|
||||||
|
container_name: ironmount
|
||||||
|
restart: unless-stopped
|
||||||
|
- cap_add:
|
||||||
|
- - SYS_ADMIN
|
||||||
|
+ privileged: true
|
||||||
|
ports:
|
||||||
|
- "4096:4096"
|
||||||
|
devices:
|
||||||
|
- /dev/fuse:/dev/fuse
|
||||||
|
volumes:
|
||||||
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
|
+ - /proc:/host/proc
|
||||||
|
+ - /run/docker/plugins:/run/docker/plugins
|
||||||
|
+ - /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Ironmount container to apply the changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Your Ironmount volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v im-nfs:/path/in/container nginx:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using Docker Compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
myservice:
|
||||||
|
image: nginx:latest
|
||||||
|
volumes:
|
||||||
|
- im-nfs:/path/in/container
|
||||||
|
volumes:
|
||||||
|
im-nfs:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
The volume name format is `im-<volume-name>` where `<volume-name>` is the name you assigned to the volume in Ironmount. You can verify that the volume is available by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls
|
||||||
|
```
|
||||||
|
|
||||||
## Third-Party Software
|
## Third-Party Software
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
logout,
|
logout,
|
||||||
getMe,
|
getMe,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
changePassword,
|
||||||
listVolumes,
|
listVolumes,
|
||||||
createVolume,
|
createVolume,
|
||||||
testConnection,
|
testConnection,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
unmountVolume,
|
unmountVolume,
|
||||||
healthCheckVolume,
|
healthCheckVolume,
|
||||||
listFiles,
|
listFiles,
|
||||||
|
browseFilesystem,
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
getSnapshotDetails,
|
getSnapshotDetails,
|
||||||
listSnapshotFiles,
|
listSnapshotFiles,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
|
doctorRepository,
|
||||||
listBackupSchedules,
|
listBackupSchedules,
|
||||||
createBackupSchedule,
|
createBackupSchedule,
|
||||||
deleteBackupSchedule,
|
deleteBackupSchedule,
|
||||||
@@ -33,6 +36,9 @@ import {
|
|||||||
updateBackupSchedule,
|
updateBackupSchedule,
|
||||||
getBackupScheduleForVolume,
|
getBackupScheduleForVolume,
|
||||||
runBackupNow,
|
runBackupNow,
|
||||||
|
stopBackup,
|
||||||
|
getSystemInfo,
|
||||||
|
downloadResticPassword,
|
||||||
} from "../sdk.gen";
|
} from "../sdk.gen";
|
||||||
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
@@ -44,6 +50,8 @@ import type {
|
|||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
GetMeData,
|
GetMeData,
|
||||||
GetStatusData,
|
GetStatusData,
|
||||||
|
ChangePasswordData,
|
||||||
|
ChangePasswordResponse,
|
||||||
ListVolumesData,
|
ListVolumesData,
|
||||||
CreateVolumeData,
|
CreateVolumeData,
|
||||||
CreateVolumeResponse,
|
CreateVolumeResponse,
|
||||||
@@ -62,6 +70,7 @@ import type {
|
|||||||
HealthCheckVolumeData,
|
HealthCheckVolumeData,
|
||||||
HealthCheckVolumeResponse,
|
HealthCheckVolumeResponse,
|
||||||
ListFilesData,
|
ListFilesData,
|
||||||
|
BrowseFilesystemData,
|
||||||
ListRepositoriesData,
|
ListRepositoriesData,
|
||||||
CreateRepositoryData,
|
CreateRepositoryData,
|
||||||
CreateRepositoryResponse,
|
CreateRepositoryResponse,
|
||||||
@@ -73,6 +82,8 @@ import type {
|
|||||||
ListSnapshotFilesData,
|
ListSnapshotFilesData,
|
||||||
RestoreSnapshotData,
|
RestoreSnapshotData,
|
||||||
RestoreSnapshotResponse,
|
RestoreSnapshotResponse,
|
||||||
|
DoctorRepositoryData,
|
||||||
|
DoctorRepositoryResponse,
|
||||||
ListBackupSchedulesData,
|
ListBackupSchedulesData,
|
||||||
CreateBackupScheduleData,
|
CreateBackupScheduleData,
|
||||||
CreateBackupScheduleResponse,
|
CreateBackupScheduleResponse,
|
||||||
@@ -84,6 +95,11 @@ import type {
|
|||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
RunBackupNowData,
|
RunBackupNowData,
|
||||||
RunBackupNowResponse,
|
RunBackupNowResponse,
|
||||||
|
StopBackupData,
|
||||||
|
StopBackupResponse,
|
||||||
|
GetSystemInfoData,
|
||||||
|
DownloadResticPasswordData,
|
||||||
|
DownloadResticPasswordResponse,
|
||||||
} from "../types.gen";
|
} from "../types.gen";
|
||||||
import { client as _heyApiClient } from "../client.gen";
|
import { client as _heyApiClient } from "../client.gen";
|
||||||
|
|
||||||
@@ -283,6 +299,46 @@ export const getStatusOptions = (options?: Options<GetStatusData>) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const changePasswordQueryKey = (options?: Options<ChangePasswordData>) =>
|
||||||
|
createQueryKey("changePassword", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change current user password
|
||||||
|
*/
|
||||||
|
export const changePasswordOptions = (options?: Options<ChangePasswordData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await changePassword({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: changePasswordQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change current user password
|
||||||
|
*/
|
||||||
|
export const changePasswordMutation = (
|
||||||
|
options?: Partial<Options<ChangePasswordData>>,
|
||||||
|
): UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await changePassword({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
|
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -599,6 +655,27 @@ export const listFilesOptions = (options: Options<ListFilesData>) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const browseFilesystemQueryKey = (options?: Options<BrowseFilesystemData>) =>
|
||||||
|
createQueryKey("browseFilesystem", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse directories on the host filesystem
|
||||||
|
*/
|
||||||
|
export const browseFilesystemOptions = (options?: Options<BrowseFilesystemData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await browseFilesystem({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: browseFilesystemQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) =>
|
export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) =>
|
||||||
createQueryKey("listRepositories", options);
|
createQueryKey("listRepositories", options);
|
||||||
|
|
||||||
@@ -801,6 +878,46 @@ export const restoreSnapshotMutation = (
|
|||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const doctorRepositoryQueryKey = (options: Options<DoctorRepositoryData>) =>
|
||||||
|
createQueryKey("doctorRepository", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
|
*/
|
||||||
|
export const doctorRepositoryOptions = (options: Options<DoctorRepositoryData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await doctorRepository({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: doctorRepositoryQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
|
*/
|
||||||
|
export const doctorRepositoryMutation = (
|
||||||
|
options?: Partial<Options<DoctorRepositoryData>>,
|
||||||
|
): UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await doctorRepository({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
||||||
createQueryKey("listBackupSchedules", options);
|
createQueryKey("listBackupSchedules", options);
|
||||||
|
|
||||||
@@ -992,3 +1109,106 @@ export const runBackupNowMutation = (
|
|||||||
};
|
};
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const stopBackupQueryKey = (options: Options<StopBackupData>) => createQueryKey("stopBackup", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a backup that is currently in progress
|
||||||
|
*/
|
||||||
|
export const stopBackupOptions = (options: Options<StopBackupData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await stopBackup({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: stopBackupQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a backup that is currently in progress
|
||||||
|
*/
|
||||||
|
export const stopBackupMutation = (
|
||||||
|
options?: Partial<Options<StopBackupData>>,
|
||||||
|
): UseMutationOptions<StopBackupResponse, DefaultError, Options<StopBackupData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<StopBackupResponse, DefaultError, Options<StopBackupData>> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await stopBackup({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey("getSystemInfo", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system information including available capabilities
|
||||||
|
*/
|
||||||
|
export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getSystemInfo({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getSystemInfoQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadResticPasswordQueryKey = (options?: Options<DownloadResticPasswordData>) =>
|
||||||
|
createQueryKey("downloadResticPassword", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||||
|
*/
|
||||||
|
export const downloadResticPasswordOptions = (options?: Options<DownloadResticPasswordData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await downloadResticPassword({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: downloadResticPasswordQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||||
|
*/
|
||||||
|
export const downloadResticPasswordMutation = (
|
||||||
|
options?: Partial<Options<DownloadResticPasswordData>>,
|
||||||
|
): UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
DownloadResticPasswordResponse,
|
||||||
|
DefaultError,
|
||||||
|
Options<DownloadResticPasswordData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async (localOptions) => {
|
||||||
|
const { data } = await downloadResticPassword({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import type { Options as ClientOptions, TDataShape, Client } from "./client";
|
|||||||
import type {
|
import type {
|
||||||
RegisterData,
|
RegisterData,
|
||||||
RegisterResponses,
|
RegisterResponses,
|
||||||
RegisterErrors,
|
|
||||||
LoginData,
|
LoginData,
|
||||||
LoginResponses,
|
LoginResponses,
|
||||||
LoginErrors,
|
|
||||||
LogoutData,
|
LogoutData,
|
||||||
LogoutResponses,
|
LogoutResponses,
|
||||||
GetMeData,
|
GetMeData,
|
||||||
GetMeResponses,
|
GetMeResponses,
|
||||||
GetStatusData,
|
GetStatusData,
|
||||||
GetStatusResponses,
|
GetStatusResponses,
|
||||||
|
ChangePasswordData,
|
||||||
|
ChangePasswordResponses,
|
||||||
ListVolumesData,
|
ListVolumesData,
|
||||||
ListVolumesResponses,
|
ListVolumesResponses,
|
||||||
CreateVolumeData,
|
CreateVolumeData,
|
||||||
@@ -40,6 +40,8 @@ import type {
|
|||||||
HealthCheckVolumeErrors,
|
HealthCheckVolumeErrors,
|
||||||
ListFilesData,
|
ListFilesData,
|
||||||
ListFilesResponses,
|
ListFilesResponses,
|
||||||
|
BrowseFilesystemData,
|
||||||
|
BrowseFilesystemResponses,
|
||||||
ListRepositoriesData,
|
ListRepositoriesData,
|
||||||
ListRepositoriesResponses,
|
ListRepositoriesResponses,
|
||||||
CreateRepositoryData,
|
CreateRepositoryData,
|
||||||
@@ -56,6 +58,8 @@ import type {
|
|||||||
ListSnapshotFilesResponses,
|
ListSnapshotFilesResponses,
|
||||||
RestoreSnapshotData,
|
RestoreSnapshotData,
|
||||||
RestoreSnapshotResponses,
|
RestoreSnapshotResponses,
|
||||||
|
DoctorRepositoryData,
|
||||||
|
DoctorRepositoryResponses,
|
||||||
ListBackupSchedulesData,
|
ListBackupSchedulesData,
|
||||||
ListBackupSchedulesResponses,
|
ListBackupSchedulesResponses,
|
||||||
CreateBackupScheduleData,
|
CreateBackupScheduleData,
|
||||||
@@ -70,6 +74,13 @@ import type {
|
|||||||
GetBackupScheduleForVolumeResponses,
|
GetBackupScheduleForVolumeResponses,
|
||||||
RunBackupNowData,
|
RunBackupNowData,
|
||||||
RunBackupNowResponses,
|
RunBackupNowResponses,
|
||||||
|
StopBackupData,
|
||||||
|
StopBackupResponses,
|
||||||
|
StopBackupErrors,
|
||||||
|
GetSystemInfoData,
|
||||||
|
GetSystemInfoResponses,
|
||||||
|
DownloadResticPasswordData,
|
||||||
|
DownloadResticPasswordResponses,
|
||||||
} from "./types.gen";
|
} from "./types.gen";
|
||||||
import { client as _heyApiClient } from "./client.gen";
|
import { client as _heyApiClient } from "./client.gen";
|
||||||
|
|
||||||
@@ -94,7 +105,7 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
|
|||||||
* Register a new user
|
* Register a new user
|
||||||
*/
|
*/
|
||||||
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
|
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
|
||||||
return (options?.client ?? _heyApiClient).post<RegisterResponses, RegisterErrors, ThrowOnError>({
|
return (options?.client ?? _heyApiClient).post<RegisterResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/auth/register",
|
url: "/api/v1/auth/register",
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -108,7 +119,7 @@ export const register = <ThrowOnError extends boolean = false>(options?: Options
|
|||||||
* Login with username and password
|
* Login with username and password
|
||||||
*/
|
*/
|
||||||
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
|
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
|
||||||
return (options?.client ?? _heyApiClient).post<LoginResponses, LoginErrors, ThrowOnError>({
|
return (options?.client ?? _heyApiClient).post<LoginResponses, unknown, ThrowOnError>({
|
||||||
url: "/api/v1/auth/login",
|
url: "/api/v1/auth/login",
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -148,6 +159,22 @@ export const getStatus = <ThrowOnError extends boolean = false>(options?: Option
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change current user password
|
||||||
|
*/
|
||||||
|
export const changePassword = <ThrowOnError extends boolean = false>(
|
||||||
|
options?: Options<ChangePasswordData, ThrowOnError>,
|
||||||
|
) => {
|
||||||
|
return (options?.client ?? _heyApiClient).post<ChangePasswordResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/auth/change-password",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
@@ -288,6 +315,18 @@ export const listFiles = <ThrowOnError extends boolean = false>(options: Options
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse directories on the host filesystem
|
||||||
|
*/
|
||||||
|
export const browseFilesystem = <ThrowOnError extends boolean = false>(
|
||||||
|
options?: Options<BrowseFilesystemData, ThrowOnError>,
|
||||||
|
) => {
|
||||||
|
return (options?.client ?? _heyApiClient).get<BrowseFilesystemResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/volumes/filesystem/browse",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all repositories
|
* List all repositories
|
||||||
*/
|
*/
|
||||||
@@ -392,6 +431,18 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||||
|
*/
|
||||||
|
export const doctorRepository = <ThrowOnError extends boolean = false>(
|
||||||
|
options: Options<DoctorRepositoryData, ThrowOnError>,
|
||||||
|
) => {
|
||||||
|
return (options.client ?? _heyApiClient).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/repositories/{name}/doctor",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all backup schedules
|
* List all backup schedules
|
||||||
*/
|
*/
|
||||||
@@ -483,3 +534,41 @@ export const runBackupNow = <ThrowOnError extends boolean = false>(
|
|||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a backup that is currently in progress
|
||||||
|
*/
|
||||||
|
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? _heyApiClient).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
|
||||||
|
url: "/api/v1/backups/{scheduleId}/stop",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system information including available capabilities
|
||||||
|
*/
|
||||||
|
export const getSystemInfo = <ThrowOnError extends boolean = false>(
|
||||||
|
options?: Options<GetSystemInfoData, ThrowOnError>,
|
||||||
|
) => {
|
||||||
|
return (options?.client ?? _heyApiClient).get<GetSystemInfoResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/system/info",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the Restic password file for backup recovery. Requires password re-authentication.
|
||||||
|
*/
|
||||||
|
export const downloadResticPassword = <ThrowOnError extends boolean = false>(
|
||||||
|
options?: Options<DownloadResticPasswordData, ThrowOnError>,
|
||||||
|
) => {
|
||||||
|
return (options?.client ?? _heyApiClient).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/system/restic-password",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,13 +10,6 @@ export type RegisterData = {
|
|||||||
url: "/api/v1/auth/register";
|
url: "/api/v1/auth/register";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RegisterErrors = {
|
|
||||||
/**
|
|
||||||
* Invalid request or username already exists
|
|
||||||
*/
|
|
||||||
400: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RegisterResponses = {
|
export type RegisterResponses = {
|
||||||
/**
|
/**
|
||||||
* User created successfully
|
* User created successfully
|
||||||
@@ -25,6 +18,7 @@ export type RegisterResponses = {
|
|||||||
message: string;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user?: {
|
user?: {
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
@@ -43,13 +37,6 @@ export type LoginData = {
|
|||||||
url: "/api/v1/auth/login";
|
url: "/api/v1/auth/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoginErrors = {
|
|
||||||
/**
|
|
||||||
* Invalid credentials
|
|
||||||
*/
|
|
||||||
401: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LoginResponses = {
|
export type LoginResponses = {
|
||||||
/**
|
/**
|
||||||
* Login successful
|
* Login successful
|
||||||
@@ -58,6 +45,7 @@ export type LoginResponses = {
|
|||||||
message: string;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user?: {
|
user?: {
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
@@ -99,6 +87,7 @@ export type GetMeResponses = {
|
|||||||
message: string;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user?: {
|
user?: {
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
@@ -125,6 +114,28 @@ export type GetStatusResponses = {
|
|||||||
|
|
||||||
export type GetStatusResponse = GetStatusResponses[keyof GetStatusResponses];
|
export type GetStatusResponse = GetStatusResponses[keyof GetStatusResponses];
|
||||||
|
|
||||||
|
export type ChangePasswordData = {
|
||||||
|
body?: {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/auth/change-password";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangePasswordResponses = {
|
||||||
|
/**
|
||||||
|
* Password changed successfully
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChangePasswordResponse = ChangePasswordResponses[keyof ChangePasswordResponses];
|
||||||
|
|
||||||
export type ListVolumesData = {
|
export type ListVolumesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -141,6 +152,8 @@ export type ListVolumesResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -148,6 +161,7 @@ export type ListVolumesResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -158,6 +172,7 @@ export type ListVolumesResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -165,6 +180,7 @@ export type ListVolumesResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -186,6 +202,8 @@ export type CreateVolumeData = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -193,6 +211,7 @@ export type CreateVolumeData = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -203,6 +222,7 @@ export type CreateVolumeData = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -210,6 +230,7 @@ export type CreateVolumeData = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -229,6 +250,8 @@ export type CreateVolumeResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -236,6 +259,7 @@ export type CreateVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -246,6 +270,7 @@ export type CreateVolumeResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -253,6 +278,7 @@ export type CreateVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -274,6 +300,8 @@ export type TestConnectionData = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -281,6 +309,7 @@ export type TestConnectionData = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -291,6 +320,7 @@ export type TestConnectionData = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -298,6 +328,7 @@ export type TestConnectionData = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -370,6 +401,8 @@ export type GetVolumeResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -377,6 +410,7 @@ export type GetVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -387,6 +421,7 @@ export type GetVolumeResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -394,6 +429,7 @@ export type GetVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -417,6 +453,8 @@ export type UpdateVolumeData = {
|
|||||||
config?:
|
config?:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -424,6 +462,7 @@ export type UpdateVolumeData = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -434,6 +473,7 @@ export type UpdateVolumeData = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -441,6 +481,7 @@ export type UpdateVolumeData = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -468,6 +509,8 @@ export type UpdateVolumeResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -475,6 +518,7 @@ export type UpdateVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -485,6 +529,7 @@ export type UpdateVolumeResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -492,6 +537,7 @@ export type UpdateVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -641,6 +687,36 @@ export type ListFilesResponses = {
|
|||||||
|
|
||||||
export type ListFilesResponse = ListFilesResponses[keyof ListFilesResponses];
|
export type ListFilesResponse = ListFilesResponses[keyof ListFilesResponses];
|
||||||
|
|
||||||
|
export type BrowseFilesystemData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Directory path to browse (absolute path, defaults to /)
|
||||||
|
*/
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
url: "/api/v1/volumes/filesystem/browse";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowseFilesystemResponses = {
|
||||||
|
/**
|
||||||
|
* List of directories in the specified path
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
directories: Array<{
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "directory" | "file";
|
||||||
|
modifiedAt?: number;
|
||||||
|
size?: number;
|
||||||
|
}>;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowseFilesystemResponse = BrowseFilesystemResponses[keyof BrowseFilesystemResponses];
|
||||||
|
|
||||||
export type ListRepositoriesData = {
|
export type ListRepositoriesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -896,6 +972,32 @@ export type RestoreSnapshotResponses = {
|
|||||||
|
|
||||||
export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses];
|
export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnapshotResponses];
|
||||||
|
|
||||||
|
export type DoctorRepositoryData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/repositories/{name}/doctor";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoctorRepositoryResponses = {
|
||||||
|
/**
|
||||||
|
* Doctor operation completed
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
steps: Array<{
|
||||||
|
error: string | null;
|
||||||
|
output: string | null;
|
||||||
|
step: string;
|
||||||
|
success: boolean;
|
||||||
|
}>;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
|
||||||
|
|
||||||
export type ListBackupSchedulesData = {
|
export type ListBackupSchedulesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -916,7 +1018,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: "error" | "success" | null;
|
lastBackupStatus: "error" | "in_progress" | "success" | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
|
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
|
||||||
@@ -957,6 +1059,8 @@ export type ListBackupSchedulesResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -964,6 +1068,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -974,6 +1079,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -981,6 +1087,7 @@ export type ListBackupSchedulesResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -1036,7 +1143,7 @@ export type CreateBackupScheduleResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: "error" | "success" | null;
|
lastBackupStatus: "error" | "in_progress" | "success" | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
@@ -1097,7 +1204,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: "error" | "success" | null;
|
lastBackupStatus: "error" | "in_progress" | "success" | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
|
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
|
||||||
@@ -1138,6 +1245,8 @@ export type GetBackupScheduleResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -1145,6 +1254,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -1155,6 +1265,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -1162,6 +1273,7 @@ export type GetBackupScheduleResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -1218,7 +1330,7 @@ export type UpdateBackupScheduleResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: "error" | "success" | null;
|
lastBackupStatus: "error" | "in_progress" | "success" | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
retentionPolicy: {
|
retentionPolicy: {
|
||||||
@@ -1259,7 +1371,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
includePatterns: Array<string> | null;
|
includePatterns: Array<string> | null;
|
||||||
lastBackupAt: number | null;
|
lastBackupAt: number | null;
|
||||||
lastBackupError: string | null;
|
lastBackupError: string | null;
|
||||||
lastBackupStatus: "error" | "success" | null;
|
lastBackupStatus: "error" | "in_progress" | "success" | null;
|
||||||
nextBackupAt: number | null;
|
nextBackupAt: number | null;
|
||||||
repository: {
|
repository: {
|
||||||
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
|
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
|
||||||
@@ -1300,6 +1412,8 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
config:
|
config:
|
||||||
| {
|
| {
|
||||||
backend: "directory";
|
backend: "directory";
|
||||||
|
path: string;
|
||||||
|
readOnly?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "nfs";
|
backend: "nfs";
|
||||||
@@ -1307,6 +1421,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
version: "3" | "4" | "4.1";
|
version: "3" | "4" | "4.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "smb";
|
backend: "smb";
|
||||||
@@ -1317,6 +1432,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
vers?: "1.0" | "2.0" | "2.1" | "3.0";
|
||||||
port?: number;
|
port?: number;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
backend: "webdav";
|
backend: "webdav";
|
||||||
@@ -1324,6 +1440,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
server: string;
|
server: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
@@ -1363,6 +1480,71 @@ export type RunBackupNowResponses = {
|
|||||||
|
|
||||||
export type RunBackupNowResponse = RunBackupNowResponses[keyof RunBackupNowResponses];
|
export type RunBackupNowResponse = RunBackupNowResponses[keyof RunBackupNowResponses];
|
||||||
|
|
||||||
|
export type StopBackupData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
scheduleId: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/backups/{scheduleId}/stop";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StopBackupErrors = {
|
||||||
|
/**
|
||||||
|
* No backup is currently running for this schedule
|
||||||
|
*/
|
||||||
|
409: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StopBackupResponses = {
|
||||||
|
/**
|
||||||
|
* Backup stopped successfully
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StopBackupResponse = StopBackupResponses[keyof StopBackupResponses];
|
||||||
|
|
||||||
|
export type GetSystemInfoData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/system/info";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetSystemInfoResponses = {
|
||||||
|
/**
|
||||||
|
* System information with enabled capabilities
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
capabilities: {
|
||||||
|
docker: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetSystemInfoResponse = GetSystemInfoResponses[keyof GetSystemInfoResponses];
|
||||||
|
|
||||||
|
export type DownloadResticPasswordData = {
|
||||||
|
body?: {
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/system/restic-password";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownloadResticPasswordResponses = {
|
||||||
|
/**
|
||||||
|
* Restic password file content
|
||||||
|
*/
|
||||||
|
200: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownloadResticPasswordResponse = DownloadResticPasswordResponses[keyof DownloadResticPasswordResponses];
|
||||||
|
|
||||||
export type ClientOptions = {
|
export type ClientOptions = {
|
||||||
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
--breakpoint-xs: 32rem;
|
||||||
--font-sans:
|
--font-sans:
|
||||||
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
"Noto Color Emoji";
|
"Noto Color Emoji";
|
||||||
@@ -12,16 +13,16 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@apply bg-white dark:bg-[#131313];
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-[#131313];
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
@@ -107,6 +108,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
--background: #131313;
|
--background: #131313;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: #131313;
|
--card: #131313;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CalendarClock, Database, HardDrive, Mountain } from "lucide-react";
|
import { CalendarClock, Database, HardDrive, Mountain, Settings } from "lucide-react";
|
||||||
import { Link, NavLink } from "react-router";
|
import { Link, NavLink } from "react-router";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -30,6 +30,11 @@ const items = [
|
|||||||
url: "/backups",
|
url: "/backups",
|
||||||
icon: CalendarClock,
|
icon: CalendarClock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
url: "/settings",
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type AuthLayoutProps = {
|
|||||||
|
|
||||||
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex mt-[25%] lg:mt-0 lg:min-h-screen">
|
||||||
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
<div className="flex flex-1 items-center justify-center bg-background p-8">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -26,8 +26,8 @@ export function AuthLayout({ title, description, children }: AuthLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="hidden lg:block lg:flex-1 dither-xl bg-cover bg-center"
|
className="hidden lg:block lg:flex-1 dither-lg bg-cover bg-center"
|
||||||
style={{ backgroundImage: "url(/background.jpg)" }}
|
style={{ backgroundImage: "url(/images/background.jpg)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { cn, slugify } from "~/lib/utils";
|
import { cn, slugify } from "~/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
import { deepClean } from "~/utils/object";
|
||||||
|
import { DirectoryBrowser } from "./directory-browser";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
@@ -30,7 +31,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultValuesForType = {
|
const defaultValuesForType = {
|
||||||
directory: { backend: "directory" as const },
|
directory: { backend: "directory" as const, path: "/" },
|
||||||
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 },
|
||||||
@@ -52,8 +53,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
const watchedName = watch("name");
|
const watchedName = watch("name");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mode === "create") {
|
||||||
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
|
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
|
||||||
}, [watchedBackend, watchedName, form.reset]);
|
}
|
||||||
|
}, [watchedBackend, watchedName, form.reset, mode]);
|
||||||
|
|
||||||
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
@@ -133,6 +136,39 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{watchedBackend === "directory" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => {
|
||||||
|
const [showBrowser, setShowBrowser] = useState(!field.value || field.value === "/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Directory Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{!showBrowser && field.value ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 border rounded-md p-3 bg-muted/50">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div>
|
||||||
|
<div className="text-sm font-mono break-all">{field.value}</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setShowBrowser(true)}>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{watchedBackend === "nfs" && (
|
{watchedBackend === "nfs" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -202,6 +238,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="readOnly"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Read-only Mode</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Mount volume as read-only</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -301,6 +362,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="readOnly"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Read-only Mode</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Mount volume as read-only</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -422,6 +508,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="readOnly"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Read-only Mode</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Mount volume as read-only</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
133
apps/client/app/components/directory-browser.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { browseFilesystemOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
|
import { FileTree, type FileEntry } from "./file-tree";
|
||||||
|
import { ScrollArea } from "./ui/scroll-area";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSelectPath: (path: string) => void;
|
||||||
|
selectedPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
|
||||||
|
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
...browseFilesystemOptions({ query: { path: "/" } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
useMemo(() => {
|
||||||
|
if (data?.directories) {
|
||||||
|
setAllFiles((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const dir of data.directories) {
|
||||||
|
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||||
|
|
||||||
|
const handleFolderExpand = useCallback(
|
||||||
|
async (folderPath: string) => {
|
||||||
|
setExpandedFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(folderPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fetchedFolders.has(folderPath)) {
|
||||||
|
setLoadingFolders((prev) => new Set(prev).add(folderPath));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await queryClient.fetchQuery(
|
||||||
|
browseFilesystemOptions({
|
||||||
|
query: { path: folderPath },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.directories) {
|
||||||
|
setAllFiles((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const dir of result.directories) {
|
||||||
|
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFetchedFolders((prev) => new Set(prev).add(folderPath));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch folder contents:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(folderPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchedFolders, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderHover = useCallback(
|
||||||
|
(folderPath: string) => {
|
||||||
|
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||||
|
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchedFolders, loadingFolders, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading && fileArray.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="text-sm text-gray-500 p-4">Loading directories...</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileArray.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="text-sm text-gray-500 p-4">No subdirectories found</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<FileTree
|
||||||
|
files={fileArray}
|
||||||
|
onFolderExpand={handleFolderExpand}
|
||||||
|
onFolderHover={handleFolderHover}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
loadingFolders={loadingFolders}
|
||||||
|
foldersOnly
|
||||||
|
selectableFolders
|
||||||
|
selectedFolder={selectedPath}
|
||||||
|
onFolderSelect={onSelectPath}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{selectedPath && (
|
||||||
|
<div className="bg-muted/50 border-t p-2 text-sm">
|
||||||
|
<div className="font-medium text-muted-foreground">Selected path:</div>
|
||||||
|
<div className="font-mono text-xs break-all">{selectedPath}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -36,6 +36,9 @@ interface Props {
|
|||||||
selectedPaths?: Set<string>;
|
selectedPaths?: Set<string>;
|
||||||
onSelectionChange?: (selectedPaths: Set<string>) => void;
|
onSelectionChange?: (selectedPaths: Set<string>) => void;
|
||||||
foldersOnly?: boolean;
|
foldersOnly?: boolean;
|
||||||
|
selectableFolders?: boolean;
|
||||||
|
onFolderSelect?: (folderPath: string) => void;
|
||||||
|
selectedFolder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileTree = memo((props: Props) => {
|
export const FileTree = memo((props: Props) => {
|
||||||
@@ -52,6 +55,9 @@ export const FileTree = memo((props: Props) => {
|
|||||||
selectedPaths = new Set(),
|
selectedPaths = new Set(),
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
foldersOnly = false,
|
foldersOnly = false,
|
||||||
|
selectableFolders = false,
|
||||||
|
onFolderSelect,
|
||||||
|
selectedFolder,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const fileList = useMemo(() => {
|
const fileList = useMemo(() => {
|
||||||
@@ -126,6 +132,13 @@ export const FileTree = memo((props: Props) => {
|
|||||||
[onFileSelect],
|
[onFileSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleFolderSelect = useCallback(
|
||||||
|
(folderPath: string) => {
|
||||||
|
onFolderSelect?.(folderPath);
|
||||||
|
},
|
||||||
|
[onFolderSelect],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectionChange = useCallback(
|
const handleSelectionChange = useCallback(
|
||||||
(path: string, checked: boolean) => {
|
(path: string, checked: boolean) => {
|
||||||
const newSelection = new Set(selectedPaths);
|
const newSelection = new Set(selectedPaths);
|
||||||
@@ -299,6 +312,9 @@ export const FileTree = memo((props: Props) => {
|
|||||||
checked={isPathSelected(fileOrFolder.fullPath) && !isPartiallySelected(fileOrFolder.fullPath)}
|
checked={isPathSelected(fileOrFolder.fullPath) && !isPartiallySelected(fileOrFolder.fullPath)}
|
||||||
partiallyChecked={isPartiallySelected(fileOrFolder.fullPath)}
|
partiallyChecked={isPartiallySelected(fileOrFolder.fullPath)}
|
||||||
onCheckboxChange={handleSelectionChange}
|
onCheckboxChange={handleSelectionChange}
|
||||||
|
selectableMode={selectableFolders}
|
||||||
|
onFolderSelect={handleFolderSelect}
|
||||||
|
selected={selectedFolder === fileOrFolder.fullPath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -321,6 +337,9 @@ interface FolderProps {
|
|||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
partiallyChecked?: boolean;
|
partiallyChecked?: boolean;
|
||||||
onCheckboxChange?: (path: string, checked: boolean) => void;
|
onCheckboxChange?: (path: string, checked: boolean) => void;
|
||||||
|
selectableMode?: boolean;
|
||||||
|
onFolderSelect?: (folderPath: string) => void;
|
||||||
|
selected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Folder = memo(
|
const Folder = memo(
|
||||||
@@ -334,6 +353,9 @@ const Folder = memo(
|
|||||||
checked,
|
checked,
|
||||||
partiallyChecked,
|
partiallyChecked,
|
||||||
onCheckboxChange,
|
onCheckboxChange,
|
||||||
|
selectableMode,
|
||||||
|
onFolderSelect,
|
||||||
|
selected,
|
||||||
}: FolderProps) => {
|
}: FolderProps) => {
|
||||||
const { depth, name, fullPath } = folder;
|
const { depth, name, fullPath } = folder;
|
||||||
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
|
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
|
||||||
@@ -359,9 +381,19 @@ const Folder = memo(
|
|||||||
[onCheckboxChange, fullPath],
|
[onCheckboxChange, fullPath],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleFolderClick = useCallback(() => {
|
||||||
|
if (selectableMode) {
|
||||||
|
onFolderSelect?.(fullPath);
|
||||||
|
}
|
||||||
|
}, [selectableMode, onFolderSelect, fullPath]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeButton
|
<NodeButton
|
||||||
className={cn("group hover:bg-accent/50 text-foreground")}
|
className={cn("group", {
|
||||||
|
"hover:bg-accent/50 text-foreground": !selected,
|
||||||
|
"bg-accent text-accent-foreground": selected,
|
||||||
|
"cursor-pointer": selectableMode,
|
||||||
|
})}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
icon={
|
icon={
|
||||||
loading ? (
|
loading ? (
|
||||||
@@ -372,6 +404,7 @@ const Folder = memo(
|
|||||||
<ChevronDown className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
|
<ChevronDown className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onClick={selectableMode ? handleFolderClick : undefined}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
>
|
>
|
||||||
{withCheckbox && (
|
{withCheckbox && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { LifeBuoy } from "lucide-react";
|
import { LifeBuoy } from "lucide-react";
|
||||||
import { Outlet, useNavigate } from "react-router";
|
import { Outlet, redirect, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { appContext } from "~/context";
|
import { appContext } from "~/context";
|
||||||
@@ -16,6 +16,11 @@ export const clientMiddleware = [authMiddleware];
|
|||||||
|
|
||||||
export async function clientLoader({ context }: Route.LoaderArgs) {
|
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||||
const ctx = context.get(appContext);
|
const ctx = context.get(appContext);
|
||||||
|
|
||||||
|
if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
|
||||||
|
throw redirect("/download-recovery-key");
|
||||||
|
}
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
</header>
|
</header>
|
||||||
<div className="main-content flex-1 overflow-y-auto">
|
<div className="main-content flex-1 overflow-y-auto">
|
||||||
<GridBackground>
|
<GridBackground>
|
||||||
<main className="flex flex-col p-2 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</GridBackground>
|
</GridBackground>
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{isOn ? enabledLabel : disabledLabel}</span>
|
<span>{isOn ? enabledLabel : disabledLabel}</span>
|
||||||
<Switch disabled={disabled} checked={isOn} onCheckedChange={toggle} />
|
<Switch
|
||||||
|
disabled={disabled}
|
||||||
|
checked={isOn}
|
||||||
|
onCheckedChange={toggle}
|
||||||
|
aria-label={isOn ? `Toggle ${enabledLabel}` : `Toggle ${disabledLabel}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/api-client/types.gen";
|
|||||||
import { ByteSize } from "~/components/bytes-size";
|
import { ByteSize } from "~/components/bytes-size";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||||
import { formatSnapshotDuration } from "~/modules/repositories/tabs/snapshots";
|
import { formatDuration } from "~/utils/utils";
|
||||||
|
|
||||||
type Snapshot = ListSnapshotsResponse[number];
|
type Snapshot = ListSnapshotsResponse[number];
|
||||||
|
|
||||||
@@ -62,9 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
|
|||||||
<TableCell className="hidden md:table-cell">
|
<TableCell className="hidden md:table-cell">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
|
||||||
{formatSnapshotDuration(snapshot.duration / 1000)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
|
<span
|
||||||
|
aria-label={status}
|
||||||
|
className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|||||||
44
apps/client/app/components/ui/alert.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
warning: "border-orange-500/20 bg-orange-500/10 text-orange-500 [&>svg]:text-orange-500",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||||
|
));
|
||||||
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
22
apps/client/app/components/ui/progress.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
@@ -2,7 +2,7 @@ import { useTheme } from "next-themes";
|
|||||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme();
|
const { theme = "dark" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ interface FileEntry {
|
|||||||
type VolumeFileBrowserProps = {
|
type VolumeFileBrowserProps = {
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
refetchInterval?: number | false;
|
|
||||||
withCheckboxes?: boolean;
|
withCheckboxes?: boolean;
|
||||||
selectedPaths?: Set<string>;
|
selectedPaths?: Set<string>;
|
||||||
onSelectionChange?: (paths: Set<string>) => void;
|
onSelectionChange?: (paths: Set<string>) => void;
|
||||||
@@ -28,7 +27,6 @@ type VolumeFileBrowserProps = {
|
|||||||
export const VolumeFileBrowser = ({
|
export const VolumeFileBrowser = ({
|
||||||
volumeName,
|
volumeName,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
refetchInterval,
|
|
||||||
withCheckboxes = false,
|
withCheckboxes = false,
|
||||||
selectedPaths,
|
selectedPaths,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@@ -46,7 +44,6 @@ export const VolumeFileBrowser = ({
|
|||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
...listFilesOptions({ path: { name: volumeName } }),
|
...listFilesOptions({ path: { name: volumeName } }),
|
||||||
enabled,
|
enabled,
|
||||||
refetchInterval,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
|
|||||||
151
apps/client/app/hooks/use-server-events.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type ServerEventType =
|
||||||
|
| "connected"
|
||||||
|
| "heartbeat"
|
||||||
|
| "backup:started"
|
||||||
|
| "backup:progress"
|
||||||
|
| "backup:completed"
|
||||||
|
| "volume:mounted"
|
||||||
|
| "volume:unmounted"
|
||||||
|
| "volume:updated";
|
||||||
|
|
||||||
|
export interface BackupEvent {
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
status?: "success" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupProgressEvent {
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
seconds_elapsed: number;
|
||||||
|
percent_done: number;
|
||||||
|
total_files: number;
|
||||||
|
files_done: number;
|
||||||
|
total_bytes: number;
|
||||||
|
bytes_done: number;
|
||||||
|
current_files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeEvent {
|
||||||
|
volumeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventHandler = (data: unknown) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to listen to Server-Sent Events (SSE) from the backend
|
||||||
|
* Automatically handles cache invalidation for backup and volume events
|
||||||
|
*/
|
||||||
|
export function useServerEvents() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const handlersRef = useRef<Map<ServerEventType, Set<EventHandler>>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource("/api/v1/events");
|
||||||
|
eventSourceRef.current = eventSource;
|
||||||
|
|
||||||
|
eventSource.addEventListener("connected", () => {
|
||||||
|
console.log("[SSE] Connected to server events");
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("heartbeat", () => {});
|
||||||
|
|
||||||
|
eventSource.addEventListener("backup:started", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as BackupEvent;
|
||||||
|
console.log("[SSE] Backup started:", data);
|
||||||
|
|
||||||
|
handlersRef.current.get("backup:started")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("backup:progress", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as BackupProgressEvent;
|
||||||
|
|
||||||
|
handlersRef.current.get("backup:progress")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("backup:completed", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as BackupEvent;
|
||||||
|
console.log("[SSE] Backup completed:", data);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
queryClient.refetchQueries();
|
||||||
|
|
||||||
|
handlersRef.current.get("backup:completed")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("volume:mounted", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as VolumeEvent;
|
||||||
|
console.log("[SSE] Volume mounted:", data);
|
||||||
|
|
||||||
|
handlersRef.current.get("volume:mounted")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("volume:unmounted", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as VolumeEvent;
|
||||||
|
console.log("[SSE] Volume unmounted:", data);
|
||||||
|
|
||||||
|
handlersRef.current.get("volume:unmounted")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("volume:updated", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as VolumeEvent;
|
||||||
|
console.log("[SSE] Volume updated:", data);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
|
||||||
|
handlersRef.current.get("volume:updated")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("volume:status_updated", (e) => {
|
||||||
|
const data = JSON.parse(e.data) as VolumeEvent;
|
||||||
|
console.log("[SSE] Volume status updated:", data);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
|
||||||
|
handlersRef.current.get("volume:updated")?.forEach((handler) => {
|
||||||
|
handler(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error("[SSE] Connection error:", error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("[SSE] Disconnecting from server events");
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
};
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const addEventListener = (event: ServerEventType, handler: EventHandler) => {
|
||||||
|
if (!handlersRef.current.has(event)) {
|
||||||
|
handlersRef.current.set(event, new Set());
|
||||||
|
}
|
||||||
|
handlersRef.current.get(event)?.add(handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handlersRef.current.get(event)?.delete(handler);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return { addEventListener };
|
||||||
|
}
|
||||||
106
apps/client/app/modules/auth/routes/download-recovery-key.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { AlertTriangle, Download } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { downloadResticPasswordMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
|
import { AuthLayout } from "~/components/auth-layout";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
|
import type { Route } from "./+types/download-recovery-key";
|
||||||
|
|
||||||
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Download Recovery Key" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Download your backup recovery key to ensure you can restore your data.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DownloadRecoveryKeyPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const downloadResticPassword = useMutation({
|
||||||
|
...downloadResticPasswordMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const blob = new Blob([data], { type: "text/plain" });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "restic.pass";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success("Recovery key downloaded successfully!");
|
||||||
|
navigate("/volumes", { replace: true });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to download recovery key", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
toast.error("Password is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadResticPassword.mutate({
|
||||||
|
body: {
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Download Your Recovery Key"
|
||||||
|
description="This is a critical step to ensure you can recover your backups"
|
||||||
|
>
|
||||||
|
<Alert variant="warning" className="mb-6">
|
||||||
|
<AlertTriangle className="size-5" />
|
||||||
|
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Your Restic password is essential for recovering your backup data. If you lose access to this server without
|
||||||
|
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Confirm Your Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={downloadResticPassword.isPending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Download Recovery Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,9 +10,20 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { authMiddleware } from "~/middleware/auth";
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
|
import type { Route } from "./+types/login";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Login" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Sign in to your Ironmount account.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const loginSchema = type({
|
const loginSchema = type({
|
||||||
username: "2<=string<=50",
|
username: "2<=string<=50",
|
||||||
password: "string>=1",
|
password: "string>=1",
|
||||||
@@ -33,8 +44,12 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const login = useMutation({
|
const login = useMutation({
|
||||||
...loginMutation(),
|
...loginMutation(),
|
||||||
onSuccess: async () => {
|
onSuccess: async (data) => {
|
||||||
|
if (data.user && !data.user.hasDownloadedResticPassword) {
|
||||||
|
navigate("/download-recovery-key");
|
||||||
|
} else {
|
||||||
navigate("/volumes");
|
navigate("/volumes");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -10,9 +10,20 @@ import { Button } from "~/components/ui/button";
|
|||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { authMiddleware } from "~/middleware/auth";
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
|
import type { Route } from "./+types/onboarding";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Onboarding" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Welcome to Ironmount. Create your admin account to get started.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const onboardingSchema = type({
|
const onboardingSchema = type({
|
||||||
username: "2<=string<=50",
|
username: "2<=string<=50",
|
||||||
password: "string>=8",
|
password: "string>=8",
|
||||||
@@ -37,7 +48,7 @@ export default function OnboardingPage() {
|
|||||||
...registerMutation(),
|
...registerMutation(),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success("Admin user created successfully!");
|
toast.success("Admin user created successfully!");
|
||||||
navigate("/volumes");
|
navigate("/download-recovery-key");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ByteSize, formatBytes } from "~/components/bytes-size";
|
||||||
|
import { Card } from "~/components/ui/card";
|
||||||
|
import { Progress } from "~/components/ui/progress";
|
||||||
|
import { type BackupProgressEvent, useServerEvents } from "~/hooks/use-server-events";
|
||||||
|
import { formatDuration } from "~/utils/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
scheduleId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BackupProgressCard = ({ scheduleId }: Props) => {
|
||||||
|
const { addEventListener } = useServerEvents();
|
||||||
|
const [progress, setProgress] = useState<BackupProgressEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = addEventListener("backup:progress", (data) => {
|
||||||
|
const progressData = data as BackupProgressEvent;
|
||||||
|
if (progressData.scheduleId === scheduleId) {
|
||||||
|
setProgress(progressData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeComplete = addEventListener("backup:completed", (data) => {
|
||||||
|
const completedData = data as { scheduleId: number };
|
||||||
|
if (completedData.scheduleId === scheduleId) {
|
||||||
|
setProgress(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeComplete();
|
||||||
|
};
|
||||||
|
}, [addEventListener, scheduleId]);
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
<span className="font-medium">Backup in progress</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentDone = Math.round(progress.percent_done * 100);
|
||||||
|
const currentFile = progress.current_files[0] || "";
|
||||||
|
const fileName = currentFile.split("/").pop() || currentFile;
|
||||||
|
const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
<span className="font-medium">Backup in progress</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-primary">{percentDone}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress value={percentDone} className="h-2" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Data</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
||||||
|
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fileName && (
|
||||||
|
<div className="pt-2 border-t border-border">
|
||||||
|
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile}>
|
||||||
|
{fileName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||||
|
|
||||||
type BackupStatus = "active" | "paused" | "error";
|
type BackupStatus = "active" | "paused" | "error" | "in_progress";
|
||||||
|
|
||||||
export const BackupStatusDot = ({ enabled, hasError }: { enabled: boolean; hasError?: boolean }) => {
|
export const BackupStatusDot = ({
|
||||||
|
enabled,
|
||||||
|
hasError,
|
||||||
|
isInProgress,
|
||||||
|
}: { enabled: boolean; hasError?: boolean; isInProgress?: boolean }) => {
|
||||||
let status: BackupStatus = "paused";
|
let status: BackupStatus = "paused";
|
||||||
if (hasError) {
|
if (isInProgress) {
|
||||||
|
status = "in_progress";
|
||||||
|
} else if (hasError) {
|
||||||
status = "error";
|
status = "error";
|
||||||
} else if (enabled) {
|
} else if (enabled) {
|
||||||
status = "active";
|
status = "active";
|
||||||
@@ -30,6 +36,12 @@ export const BackupStatusDot = ({ enabled, hasError }: { enabled: boolean; hasEr
|
|||||||
animated: true,
|
animated: true,
|
||||||
label: "Error",
|
label: "Error",
|
||||||
},
|
},
|
||||||
|
in_progress: {
|
||||||
|
color: "bg-blue-500",
|
||||||
|
colorLight: "bg-blue-400",
|
||||||
|
animated: true,
|
||||||
|
label: "Backup in progress",
|
||||||
|
},
|
||||||
}[status];
|
}[status];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
foldersOnly={true}
|
foldersOnly={true}
|
||||||
className="overflow-auto flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
|
className="max-w-2xs xs:max-w-screen flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px]"
|
||||||
/>
|
/>
|
||||||
{selectedPaths.size > 0 && (
|
{selectedPaths.size > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Pencil, Play, Trash2 } from "lucide-react";
|
import { Pencil, Play, Square, Trash2 } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { OnOff } from "~/components/onoff";
|
import { OnOff } from "~/components/onoff";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -13,17 +13,20 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import type { BackupSchedule } from "~/lib/types";
|
import type { BackupSchedule } from "~/lib/types";
|
||||||
|
import { BackupProgressCard } from "./backup-progress-card";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
schedule: BackupSchedule;
|
schedule: BackupSchedule;
|
||||||
handleToggleEnabled: (enabled: boolean) => void;
|
handleToggleEnabled: (enabled: boolean) => void;
|
||||||
handleRunBackupNow: () => void;
|
handleRunBackupNow: () => void;
|
||||||
|
handleStopBackup: () => void;
|
||||||
handleDeleteSchedule: () => void;
|
handleDeleteSchedule: () => void;
|
||||||
setIsEditMode: (isEdit: boolean) => void;
|
setIsEditMode: (isEdit: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScheduleSummary = (props: Props) => {
|
export const ScheduleSummary = (props: Props) => {
|
||||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleDeleteSchedule, setIsEditMode } = props;
|
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||||
|
props;
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
@@ -75,10 +78,17 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
{schedule.lastBackupStatus === "in_progress" ? (
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
|
||||||
|
<Square className="h-4 w-4 mr-2" />
|
||||||
|
<span className="sm:inline">Stop backup</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
|
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
|
||||||
<Play className="h-4 w-4 mr-2" />
|
<Play className="h-4 w-4 mr-2" />
|
||||||
<span className="sm:inline">Backup Now</span>
|
<span className="sm:inline">Backup now</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
<span className="sm:inline">Edit schedule</span>
|
<span className="sm:inline">Edit schedule</span>
|
||||||
@@ -121,12 +131,22 @@ export const ScheduleSummary = (props: Props) => {
|
|||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{schedule.lastBackupStatus === "success" && "✓ Success"}
|
{schedule.lastBackupStatus === "success" && "✓ Success"}
|
||||||
{schedule.lastBackupStatus === "error" && "✗ Error"}
|
{schedule.lastBackupStatus === "error" && "✗ Error"}
|
||||||
|
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
|
||||||
{!schedule.lastBackupStatus && "—"}
|
{!schedule.lastBackupStatus && "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{schedule.lastBackupError && (
|
||||||
|
<div className="md:col-span-2 lg:col-span-4">
|
||||||
|
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
|
||||||
|
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap break-all">{schedule.lastBackupError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
|
||||||
|
|
||||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|||||||
@@ -17,16 +17,20 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import type { Snapshot } from "~/lib/types";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||||
|
import type { Snapshot, Volume } from "~/lib/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
|
volume?: Volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
const { snapshot, repositoryName } = props;
|
const { snapshot, repositoryName, volume } = props;
|
||||||
|
|
||||||
|
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
@@ -195,11 +199,28 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{selectedPaths.size > 0 && (
|
{selectedPaths.size > 0 && (
|
||||||
<Button onClick={handleRestoreClick} variant="primary" size="sm" disabled={isRestoring}>
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span tabIndex={isReadOnly ? 0 : undefined}>
|
||||||
|
<Button
|
||||||
|
onClick={handleRestoreClick}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={isRestoring || isReadOnly}
|
||||||
|
>
|
||||||
{isRestoring
|
{isRestoring
|
||||||
? "Restoring..."
|
? "Restoring..."
|
||||||
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{isReadOnly && (
|
||||||
|
<TooltipContent className="text-center">
|
||||||
|
<p>Volume is mounted as read-only.</p>
|
||||||
|
<p>Please remount with read-only disabled to restore files.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -2,23 +2,52 @@ import type { ListSnapshotsResponse } from "~/api-client/types.gen";
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
import { ByteSize } from "~/components/bytes-size";
|
import { ByteSize } from "~/components/bytes-size";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshots: ListSnapshotsResponse;
|
snapshots: ListSnapshotsResponse;
|
||||||
snapshotId: string;
|
snapshotId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string;
|
||||||
onSnapshotSelect: (snapshotId: string) => void;
|
onSnapshotSelect: (snapshotId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnapshotTimeline = (props: Props) => {
|
export const SnapshotTimeline = (props: Props) => {
|
||||||
const { snapshots, snapshotId, onSnapshotSelect } = props;
|
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshotId && snapshots.length > 0) {
|
||||||
|
onSnapshotSelect(snapshots[snapshots.length - 1].short_id);
|
||||||
|
}
|
||||||
|
}, [snapshotId, snapshots, onSnapshotSelect]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-center h-24 p-4 text-center">
|
||||||
|
<p className="text-destructive">Error loading snapshots: {error}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-center h-24">
|
||||||
|
<p className="text-muted-foreground">Loading snapshots...</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (snapshots.length === 0) {
|
if (snapshots.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-card border-t border-border py-4 px-4">
|
<Card>
|
||||||
<div className="flex items-center justify-center h-24">
|
<div className="flex items-center justify-center h-24">
|
||||||
<p className="text-muted-foreground">No snapshots available</p>
|
<p className="text-muted-foreground">No snapshots available</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,52 +9,57 @@ import {
|
|||||||
deleteBackupScheduleMutation,
|
deleteBackupScheduleMutation,
|
||||||
listSnapshotsOptions,
|
listSnapshotsOptions,
|
||||||
updateBackupScheduleMutation,
|
updateBackupScheduleMutation,
|
||||||
|
stopBackupMutation,
|
||||||
} from "~/api-client/@tanstack/react-query.gen";
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/lib/errors";
|
import { parseError } from "~/lib/errors";
|
||||||
import { getCronExpression } from "~/utils/utils";
|
import { getCronExpression } from "~/utils/utils";
|
||||||
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
|
||||||
import { ScheduleSummary } from "../components/schedule-summary";
|
import { ScheduleSummary } from "../components/schedule-summary";
|
||||||
import { getBackupSchedule, listSnapshots } from "~/api-client";
|
import { getBackupSchedule } from "~/api-client";
|
||||||
import type { Route } from "./+types/backup-details";
|
import type { Route } from "./+types/backup-details";
|
||||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Backup Job Details" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "View and manage backup job configuration, schedule, and snapshots.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||||
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
|
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||||
|
|
||||||
if (!data) return redirect("/backups");
|
if (!data) return redirect("/backups");
|
||||||
|
|
||||||
const snapshots = await listSnapshots({
|
return data;
|
||||||
path: { name: data.repository.name },
|
|
||||||
query: { backupId: params.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (snapshots.data) return { snapshots: snapshots.data, schedule: data };
|
|
||||||
return { snapshots: [], schedule: data };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const formId = useId();
|
const formId = useId();
|
||||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>(loaderData.snapshots.at(-1)?.short_id ?? "");
|
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||||
|
|
||||||
const { data: schedule } = useQuery({
|
const { data: schedule } = useQuery({
|
||||||
...getBackupScheduleOptions({
|
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||||
path: { scheduleId: params.id },
|
initialData: loaderData,
|
||||||
}),
|
refetchInterval: 10000,
|
||||||
initialData: loaderData.schedule,
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: snapshots } = useQuery({
|
const {
|
||||||
...listSnapshotsOptions({
|
data: snapshots,
|
||||||
path: { name: schedule.repository.name },
|
isLoading,
|
||||||
query: { backupId: schedule.id.toString() },
|
failureReason,
|
||||||
}),
|
} = useQuery({
|
||||||
initialData: loaderData.snapshots,
|
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const upsertSchedule = useMutation({
|
const updateSchedule = useMutation({
|
||||||
...updateBackupScheduleMutation(),
|
...updateBackupScheduleMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Backup schedule saved successfully");
|
toast.success("Backup schedule saved successfully");
|
||||||
@@ -73,9 +78,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
toast.success("Backup started successfully");
|
toast.success("Backup started successfully");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to start backup", {
|
toast.error("Failed to start backup", { description: parseError(error)?.message });
|
||||||
description: parseError(error)?.message,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stopBackup = useMutation({
|
||||||
|
...stopBackupMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Backup stopped successfully");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to stop backup", { description: parseError(error)?.message });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,9 +99,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
navigate("/backups");
|
navigate("/backups");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to delete backup schedule", {
|
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
|
||||||
description: parseError(error)?.message,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +116,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
|
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
|
||||||
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
||||||
|
|
||||||
upsertSchedule.mutate({
|
updateSchedule.mutate({
|
||||||
path: { scheduleId: schedule.id.toString() },
|
path: { scheduleId: schedule.id.toString() },
|
||||||
body: {
|
body: {
|
||||||
repositoryId: formValues.repositoryId,
|
repositoryId: formValues.repositoryId,
|
||||||
@@ -119,9 +130,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleEnabled = (enabled: boolean) => {
|
const handleToggleEnabled = (enabled: boolean) => {
|
||||||
if (!schedule) return;
|
updateSchedule.mutate({
|
||||||
|
|
||||||
upsertSchedule.mutate({
|
|
||||||
path: { scheduleId: schedule.id.toString() },
|
path: { scheduleId: schedule.id.toString() },
|
||||||
body: {
|
body: {
|
||||||
repositoryId: schedule.repositoryId,
|
repositoryId: schedule.repositoryId,
|
||||||
@@ -134,28 +143,12 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRunBackupNow = () => {
|
|
||||||
if (!schedule) return;
|
|
||||||
|
|
||||||
runBackupNow.mutate({
|
|
||||||
path: {
|
|
||||||
scheduleId: schedule.id.toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSchedule = () => {
|
|
||||||
if (!schedule) return;
|
|
||||||
|
|
||||||
deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
|
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
|
||||||
<div className="flex justify-end mt-4 gap-2">
|
<div className="flex justify-end mt-4 gap-2">
|
||||||
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={upsertSchedule.isPending}>
|
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}>
|
||||||
Update schedule
|
Update schedule
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
||||||
@@ -166,30 +159,32 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedSnapshot = snapshots.find((s) => s.short_id === selectedSnapshotId);
|
const selectedSnapshot = snapshots?.find((s) => s.short_id === selectedSnapshotId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<ScheduleSummary
|
<ScheduleSummary
|
||||||
handleToggleEnabled={handleToggleEnabled}
|
handleToggleEnabled={handleToggleEnabled}
|
||||||
handleRunBackupNow={handleRunBackupNow}
|
handleRunBackupNow={() => runBackupNow.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||||
handleDeleteSchedule={handleDeleteSchedule}
|
handleStopBackup={() => stopBackup.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||||
|
handleDeleteSchedule={() => deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||||
setIsEditMode={setIsEditMode}
|
setIsEditMode={setIsEditMode}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
/>
|
/>
|
||||||
{selectedSnapshot && (
|
|
||||||
<>
|
|
||||||
<SnapshotTimeline
|
<SnapshotTimeline
|
||||||
snapshots={snapshots}
|
loading={isLoading}
|
||||||
snapshotId={selectedSnapshot.short_id}
|
snapshots={snapshots ?? []}
|
||||||
|
snapshotId={selectedSnapshot?.short_id}
|
||||||
|
error={failureReason?.message}
|
||||||
onSnapshotSelect={setSelectedSnapshotId}
|
onSnapshotSelect={setSelectedSnapshotId}
|
||||||
/>
|
/>
|
||||||
|
{selectedSnapshot && (
|
||||||
<SnapshotFileBrowser
|
<SnapshotFileBrowser
|
||||||
key={selectedSnapshot.short_id}
|
key={selectedSnapshot?.short_id}
|
||||||
snapshot={selectedSnapshot}
|
snapshot={selectedSnapshot}
|
||||||
repositoryName={schedule.repository.name}
|
repositoryName={schedule.repository.name}
|
||||||
|
volume={schedule.volume}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import type { Route } from "./+types/backups";
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Ironmount" },
|
{ title: "Backup Jobs" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,11 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
|||||||
Volume <span className="text-strong-accent">{schedule.volume.name}</span>
|
Volume <span className="text-strong-accent">{schedule.volume.name}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<BackupStatusDot enabled={schedule.enabled} hasError={!!schedule.lastBackupError} />
|
<BackupStatusDot
|
||||||
|
enabled={schedule.enabled}
|
||||||
|
hasError={!!schedule.lastBackupError}
|
||||||
|
isInProgress={schedule.lastBackupStatus === "in_progress"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="flex items-center gap-2 mt-2">
|
<CardDescription className="flex items-center gap-2 mt-2">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
@@ -98,12 +102,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
|
|||||||
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
|
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{schedule.lastBackupError && (
|
|
||||||
<div className="flex items-start justify-between text-sm gap-2">
|
|
||||||
<span className="text-muted-foreground">Error</span>
|
|
||||||
<span className="text-xs text-red-600 text-right line-clamp-2">{schedule.lastBackupError}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ import { listRepositories, listVolumes } from "~/api-client";
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Ironmount" },
|
{ title: "Create Backup Job" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
content: "Create a new automated backup job for your volumes.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ import { EmptyState } from "~/components/empty-state";
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Ironmount - Repositories" },
|
{ title: "Repositories" },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Manage your backup repositories",
|
content: "Manage your backup repositories with encryption and compression.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
import { redirect, useNavigate, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
deleteRepositoryMutation,
|
deleteRepositoryMutation,
|
||||||
|
doctorRepositoryMutation,
|
||||||
getRepositoryOptions,
|
getRepositoryOptions,
|
||||||
listSnapshotsOptions,
|
listSnapshotsOptions,
|
||||||
} from "~/api-client/@tanstack/react-query.gen";
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
@@ -24,13 +25,14 @@ import { cn } from "~/lib/utils";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { RepositoryInfoTabContent } from "../tabs/info";
|
import { RepositoryInfoTabContent } from "../tabs/info";
|
||||||
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: `Ironmount - ${params.name}` },
|
{ title: params.name },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Manage your restic backup repositories with ease.",
|
content: "View repository configuration, status, and snapshots.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -38,10 +40,13 @@ export function meta({ params }: Route.MetaArgs) {
|
|||||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
const repository = await getRepository({ path: { name: params.name ?? "" } });
|
const repository = await getRepository({ path: { name: params.name ?? "" } });
|
||||||
if (repository.data) return repository.data;
|
if (repository.data) return repository.data;
|
||||||
|
|
||||||
|
return redirect("/repositories");
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
|
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { name } = useParams<{ name: string }>();
|
const [showDoctorResults, setShowDoctorResults] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
@@ -50,17 +55,15 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
const activeTab = searchParams.get("tab") || "info";
|
const activeTab = searchParams.get("tab") || "info";
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...getRepositoryOptions({ path: { name: name ?? "" } }),
|
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
||||||
initialData: loaderData,
|
initialData: loaderData,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (name) {
|
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
|
||||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } }));
|
}, [queryClient, data.name]);
|
||||||
}
|
|
||||||
}, [name, queryClient]);
|
|
||||||
|
|
||||||
const deleteRepo = useMutation({
|
const deleteRepo = useMutation({
|
||||||
...deleteRepositoryMutation(),
|
...deleteRepositoryMutation(),
|
||||||
@@ -75,39 +78,78 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const doctorMutation = useMutation({
|
||||||
|
...doctorRepositoryMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data) {
|
||||||
|
setShowDoctorResults(true);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
toast.success("Repository doctor completed successfully");
|
||||||
|
} else {
|
||||||
|
toast.warning("Doctor completed with some issues", {
|
||||||
|
description: "Check the details for more information",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to run doctor", {
|
||||||
|
description: parseError(error)?.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleConfirmDelete = () => {
|
const handleConfirmDelete = () => {
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
deleteRepo.mutate({ path: { name: name ?? "" } });
|
deleteRepo.mutate({ path: { name: data.name } });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!name) {
|
const getStepLabel = (step: string) => {
|
||||||
return <div>Repository not found</div>;
|
switch (step) {
|
||||||
}
|
case "unlock":
|
||||||
|
return "Unlock Repository";
|
||||||
if (!data) {
|
case "check":
|
||||||
return <div>Loading...</div>;
|
return "Check Repository";
|
||||||
|
case "repair_index":
|
||||||
|
return "Repair Index";
|
||||||
|
case "recheck":
|
||||||
|
return "Re-check Repository";
|
||||||
|
default:
|
||||||
|
return step;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||||
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
|
||||||
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
|
|
||||||
{
|
|
||||||
"bg-green-500/10 text-green-500": data.status === "healthy",
|
"bg-green-500/10 text-green-500": data.status === "healthy",
|
||||||
"bg-red-500/10 text-red-500": data.status === "error",
|
"bg-red-500/10 text-red-500": data.status === "error",
|
||||||
},
|
})}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{data.status || "unknown"}
|
{data.status || "unknown"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
|
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => doctorMutation.mutate({ path: { name: data.name } })}
|
||||||
|
disabled={doctorMutation.isPending}
|
||||||
|
variant={"outline"}
|
||||||
|
>
|
||||||
|
{doctorMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Running Doctor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Run Doctor"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
|
<Button variant="destructive" onClick={() => setShowDeleteConfirm(true)} disabled={deleteRepo.isPending}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
@@ -132,8 +174,8 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
<AlertDialogTitle>Delete repository?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete the repository <strong>{name}</strong>? This action cannot be undone and
|
Are you sure you want to delete the repository <strong>{data.name}</strong>? This action cannot be undone
|
||||||
will remove all backup data.
|
and will remove all backup data.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
@@ -147,6 +189,46 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showDoctorResults} onOpenChange={setShowDoctorResults}>
|
||||||
|
<AlertDialogContent className="max-w-2xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
{doctorMutation.data && (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{doctorMutation.data.steps.map((step) => (
|
||||||
|
<div
|
||||||
|
key={step.step}
|
||||||
|
className={cn("border rounded-md p-3", {
|
||||||
|
"bg-green-500/10 border-green-500/20": step.success,
|
||||||
|
"bg-yellow-500/10 border-yellow-500/20": !step.success,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium text-sm">{getStepLabel(step.step)}</span>
|
||||||
|
<span
|
||||||
|
className={cn("text-xs px-2 py-1 rounded", {
|
||||||
|
"bg-green-500/20 text-green-500": step.success,
|
||||||
|
"bg-yellow-500/20 text-yellow-500": !step.success,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{step.success ? "Success" : "Warning"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{step.error && <p className="text-xs text-red-500 mt-1">{step.error}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setShowDoctorResults(false)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,30 @@ import { SnapshotFileBrowser } from "~/modules/backups/components/snapshot-file-
|
|||||||
import { getSnapshotDetails } from "~/api-client";
|
import { getSnapshotDetails } from "~/api-client";
|
||||||
import type { Route } from "./+types/snapshot-details";
|
import type { Route } from "./+types/snapshot-details";
|
||||||
|
|
||||||
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: `Snapshot ${params.snapshotId}` },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Browse and restore files from a backup snapshot.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } });
|
const snapshot = await getSnapshotDetails({
|
||||||
|
path: { name: params.name, snapshotId: params.snapshotId },
|
||||||
|
});
|
||||||
if (snapshot.data) return snapshot.data;
|
if (snapshot.data) return snapshot.data;
|
||||||
|
|
||||||
return redirect("/repositories");
|
return redirect("/repositories");
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
|
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>();
|
const { name, snapshotId } = useParams<{
|
||||||
|
name: string;
|
||||||
|
snapshotId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...listSnapshotFilesOptions({
|
...listSnapshotFilesOptions({
|
||||||
@@ -54,11 +69,11 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Snapshot ID:</span>
|
<span className="text-muted-foreground">Snapshot ID:</span>
|
||||||
<p className="font-mono">{data.snapshot.id}</p>
|
<p className="font-mono break-all">{data.snapshot.id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Short ID:</span>
|
<span className="text-muted-foreground">Short ID:</span>
|
||||||
<p className="font-mono">{data.snapshot.short_id}</p>
|
<p className="font-mono break-all">{data.snapshot.short_id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Hostname:</span>
|
<span className="text-muted-foreground">Hostname:</span>
|
||||||
@@ -72,7 +87,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||||||
<span className="text-muted-foreground">Paths:</span>
|
<span className="text-muted-foreground">Paths:</span>
|
||||||
<div className="space-y-1 mt-1">
|
<div className="space-y-1 mt-1">
|
||||||
{data.snapshot.paths.map((path) => (
|
{data.snapshot.paths.map((path) => (
|
||||||
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded">
|
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
|
||||||
{path}
|
{path}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -40,16 +40,17 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{repository.lastError && (
|
{repository.lastError && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4 text-red-500">Last Error</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||||
<p className="text-sm text-red-500">{repository.lastError}</p>
|
<p className="text-sm text-red-500">{repository.lastError}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||||
<div className="bg-muted/50 rounded-md p-4">
|
<div className="bg-muted/50 rounded-md p-4">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { intervalToDuration } from "date-fns";
|
|
||||||
import { Database } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
|
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
@@ -15,18 +14,6 @@ type Props = {
|
|||||||
repository: Repository;
|
repository: Repository;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatSnapshotDuration = (seconds: number) => {
|
|
||||||
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (duration.days) parts.push(`${duration.days}d`);
|
|
||||||
if (duration.hours) parts.push(`${duration.hours}h`);
|
|
||||||
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
|
||||||
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
|
||||||
|
|
||||||
return parts.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
@@ -67,16 +54,6 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFetching && !data.length) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground">Loading snapshots</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failureReason) {
|
if (failureReason) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -89,6 +66,16 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFetching && !data.length) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-muted-foreground">Loading snapshots</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
263
apps/client/app/modules/settings/routes/settings.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Download, KeyRound, User } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
changePasswordMutation,
|
||||||
|
downloadResticPasswordMutation,
|
||||||
|
logoutMutation,
|
||||||
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardTitle } from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { appContext } from "~/context";
|
||||||
|
import type { Route } from "./+types/settings";
|
||||||
|
|
||||||
|
export function meta(_: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "Settings" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Manage your account settings and preferences.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||||
|
const ctx = context.get(appContext);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||||
|
const [downloadPassword, setDownloadPassword] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const logout = useMutation({
|
||||||
|
...logoutMutation(),
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePassword = useMutation({
|
||||||
|
...changePasswordMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.success) {
|
||||||
|
toast.success("Password changed successfully. You will be logged out.");
|
||||||
|
setTimeout(() => {
|
||||||
|
logout.mutate({});
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to change password", { description: data.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to change password", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadResticPassword = useMutation({
|
||||||
|
...downloadResticPasswordMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const blob = new Blob([data], { type: "text/plain" });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "restic.pass";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success("Restic password file downloaded successfully");
|
||||||
|
setDownloadDialogOpen(false);
|
||||||
|
setDownloadPassword("");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to download Restic password", { description: error.message });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChangePassword = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast.error("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
toast.error("Password must be at least 8 characters long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changePassword.mutate({
|
||||||
|
body: {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadResticPassword = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!downloadPassword) {
|
||||||
|
toast.error("Password is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadResticPassword.mutate({
|
||||||
|
body: {
|
||||||
|
password: downloadPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-0 gap-0">
|
||||||
|
<div className="border-b border-border/50 bg-card-header p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User className="size-5" />
|
||||||
|
Account Information
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1.5">Your account details</CardDescription>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="border-t border-border/50 bg-card-header p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="size-5" />
|
||||||
|
Change Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="current-password">Current Password</Label>
|
||||||
|
<Input
|
||||||
|
id="current-password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-password">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" loading={changePassword.isPending} className="mt-4">
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="border-t border-border/50 bg-card-header p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Download className="size-5" />
|
||||||
|
Backup Recovery Key
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1.5">Download your Restic password file for disaster recovery</CardDescription>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||||
|
This file contains the encryption password used by Restic to secure your backups. Store it in a safe place
|
||||||
|
(like a password manager or encrypted storage). If you lose access to this server, you'll need this file to
|
||||||
|
recover your backup data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Download Restic Password
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<form onSubmit={handleDownloadResticPassword}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Download Restic Password</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
For security reasons, please enter your account password to download the Restic password file.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="download-password">Your Password</Label>
|
||||||
|
<Input
|
||||||
|
id="download-password"
|
||||||
|
type="password"
|
||||||
|
value={downloadPassword}
|
||||||
|
onChange={(e) => setDownloadPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDownloadDialogOpen(false);
|
||||||
|
setDownloadPassword("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={downloadResticPassword.isPending}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
deleteVolumeMutation,
|
deleteVolumeMutation,
|
||||||
getVolumeOptions,
|
getVolumeOptions,
|
||||||
|
getSystemInfoOptions,
|
||||||
mountVolumeMutation,
|
mountVolumeMutation,
|
||||||
unmountVolumeMutation,
|
unmountVolumeMutation,
|
||||||
} from "~/api-client/@tanstack/react-query.gen";
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
@@ -31,10 +32,10 @@ import { DockerTabContent } from "../tabs/docker";
|
|||||||
|
|
||||||
export function meta({ params }: Route.MetaArgs) {
|
export function meta({ params }: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: `Ironmount - ${params.name}` },
|
{ title: params.name },
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
content: "View and manage volume details, configuration, and files.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,10 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: systemInfo } = useQuery({
|
||||||
|
...getSystemInfoOptions(),
|
||||||
|
});
|
||||||
|
|
||||||
const deleteVol = useMutation({
|
const deleteVol = useMutation({
|
||||||
...deleteVolumeMutation(),
|
...deleteVolumeMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -109,18 +114,17 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { volume, statfs } = data;
|
const { volume, statfs } = data;
|
||||||
|
const dockerAvailable = systemInfo?.capabilities?.docker ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col items-start xs:items-center xs:flex-row xs:justify-between">
|
||||||
<div>
|
<div className="text-sm font-semibold mb-2 xs:mb-0 text-muted-foreground flex items-center gap-2">
|
||||||
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
<StatusDot status={volume.status} /> {volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
<VolumeIcon size={14} backend={volume?.config.backend} />
|
<VolumeIcon size={14} backend={volume?.config.backend} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => mountVol.mutate({ path: { name } })}
|
onClick={() => mountVol.mutate({ path: { name } })}
|
||||||
@@ -146,7 +150,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
<TabsList className="mb-2">
|
<TabsList className="mb-2">
|
||||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||||
<TabsTrigger value="files">Files</TabsTrigger>
|
<TabsTrigger value="files">Files</TabsTrigger>
|
||||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
{dockerAvailable && <TabsTrigger value="docker">Docker</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="info">
|
<TabsContent value="info">
|
||||||
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
<VolumeInfoTabContent volume={volume} statfs={statfs} />
|
||||||
@@ -154,9 +158,11 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
|||||||
<TabsContent value="files">
|
<TabsContent value="files">
|
||||||
<FilesTabContent volume={volume} />
|
<FilesTabContent volume={volume} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
{dockerAvailable && (
|
||||||
<TabsContent value="docker">
|
<TabsContent value="docker">
|
||||||
<DockerTabContent volume={volume} />
|
<DockerTabContent volume={volume} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type { Route } from "./+types/volumes";
|
|||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Ironmount" },
|
{ title: "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.",
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export const FilesTabContent = ({ volume }: Props) => {
|
|||||||
<VolumeFileBrowser
|
<VolumeFileBrowser
|
||||||
volumeName={volume.name}
|
volumeName={volume.name}
|
||||||
enabled={volume.status === "mounted"}
|
enabled={volume.status === "mounted"}
|
||||||
refetchInterval={10000}
|
|
||||||
className="overflow-auto flex-1 border rounded-md bg-card p-2"
|
className="overflow-auto flex-1 border rounded-md bg-card p-2"
|
||||||
emptyMessage="This volume is empty."
|
emptyMessage="This volume is empty."
|
||||||
emptyDescription="Files and folders will appear here once you add them."
|
emptyDescription="Files and folders will appear here once you add them."
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Toaster } from "~/components/ui/sonner";
|
|||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { client } from "./api-client/client.gen";
|
import { client } from "./api-client/client.gen";
|
||||||
|
import { useServerEvents } from "./hooks/use-server-events";
|
||||||
|
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
baseUrl: "/",
|
baseUrl: "/",
|
||||||
@@ -37,15 +38,21 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" style={{ colorScheme: "dark" }} className="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
|
<link rel="icon" type="image/png" href="/images/favicon/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/favicon/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/images/favicon/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Ironmount" />
|
||||||
|
<link rel="manifest" href="/images/favicon/site.webmanifest" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<body>
|
<body className="dark">
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
@@ -57,6 +64,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
useServerEvents();
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { layout, type RouteConfig, route } from "@react-router/dev/routes";
|
|||||||
export default [
|
export default [
|
||||||
route("onboarding", "./modules/auth/routes/onboarding.tsx"),
|
route("onboarding", "./modules/auth/routes/onboarding.tsx"),
|
||||||
route("login", "./modules/auth/routes/login.tsx"),
|
route("login", "./modules/auth/routes/login.tsx"),
|
||||||
|
route("download-recovery-key", "./modules/auth/routes/download-recovery-key.tsx"),
|
||||||
layout("./components/layout.tsx", [
|
layout("./components/layout.tsx", [
|
||||||
route("/", "./routes/root.tsx"),
|
route("/", "./routes/root.tsx"),
|
||||||
route("volumes", "./modules/volumes/routes/volumes.tsx"),
|
route("volumes", "./modules/volumes/routes/volumes.tsx"),
|
||||||
@@ -13,5 +14,6 @@ export default [
|
|||||||
route("repositories", "./modules/repositories/routes/repositories.tsx"),
|
route("repositories", "./modules/repositories/routes/repositories.tsx"),
|
||||||
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
|
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
|
||||||
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
|
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
|
||||||
|
route("settings", "./modules/settings/routes/settings.tsx"),
|
||||||
]),
|
]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { intervalToDuration } from "date-fns";
|
||||||
|
|
||||||
export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => {
|
export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => {
|
||||||
if (frequency === "hourly") {
|
if (frequency === "hourly") {
|
||||||
return "0 * * * *";
|
return "0 * * * *";
|
||||||
@@ -15,3 +17,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD
|
|||||||
|
|
||||||
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`;
|
return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatDuration = (seconds: number) => {
|
||||||
|
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (duration.days) parts.push(`${duration.days}d`);
|
||||||
|
if (duration.hours) parts.push(`${duration.hours}h`);
|
||||||
|
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
||||||
|
if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`);
|
||||||
|
|
||||||
|
return parts.join(" ");
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
BIN
apps/client/public/images/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
apps/client/public/images/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/client/public/images/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
apps/client/public/images/favicon/favicon.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
21
apps/client/public/images/favicon/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Ironmount",
|
||||||
|
"short_name": "Ironmount",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/images/favicon/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/favicon/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#1b1b1b",
|
||||||
|
"background_color": "#1b1b1b",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
BIN
apps/client/public/images/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
apps/client/public/images/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
@@ -29,5 +29,6 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ await Bun.build({
|
|||||||
outdir: "./dist",
|
outdir: "./dist",
|
||||||
target: "bun",
|
target: "bun",
|
||||||
env: "disable",
|
env: "disable",
|
||||||
// sourcemap: "linked",
|
sourcemap: true,
|
||||||
minify: {
|
minify: {
|
||||||
whitespace: true,
|
whitespace: true,
|
||||||
identifiers: true,
|
identifiers: true,
|
||||||
syntax: true,
|
syntax: true,
|
||||||
|
keepNames: true,
|
||||||
},
|
},
|
||||||
external: ["ssh2"],
|
external: ["ssh2"],
|
||||||
});
|
});
|
||||||
|
|||||||
1
apps/server/drizzle/0010_perfect_proemial_gods.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users_table` ADD `has_downloaded_restic_password` integer DEFAULT false NOT NULL;
|
||||||
459
apps/server/drizzle/meta/0010_snapshot.json
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "17f234ba-4123-4951-a39f-6002d537435f",
|
||||||
|
"prevId": "6a326ac0-cb3a-4c63-8800-bc86d18e0c1d",
|
||||||
|
"tables": {
|
||||||
|
"backup_schedules_table": {
|
||||||
|
"name": "backup_schedules_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"volume_id": {
|
||||||
|
"name": "volume_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"repository_id": {
|
||||||
|
"name": "repository_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"cron_expression": {
|
||||||
|
"name": "cron_expression",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"retention_policy": {
|
||||||
|
"name": "retention_policy",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exclude_patterns": {
|
||||||
|
"name": "exclude_patterns",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"include_patterns": {
|
||||||
|
"name": "include_patterns",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"last_backup_at": {
|
||||||
|
"name": "last_backup_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_backup_status": {
|
||||||
|
"name": "last_backup_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_backup_error": {
|
||||||
|
"name": "last_backup_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"next_backup_at": {
|
||||||
|
"name": "next_backup_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "volumes_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"volume_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||||
|
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||||
|
"tableFrom": "backup_schedules_table",
|
||||||
|
"tableTo": "repositories_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"repository_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"repositories_table": {
|
||||||
|
"name": "repositories_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"compression_mode": {
|
||||||
|
"name": "compression_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'auto'"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'unknown'"
|
||||||
|
},
|
||||||
|
"last_checked": {
|
||||||
|
"name": "last_checked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"repositories_table_name_unique": {
|
||||||
|
"name": "repositories_table_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions_table": {
|
||||||
|
"name": "sessions_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_table_user_id_users_table_id_fk": {
|
||||||
|
"name": "sessions_table_user_id_users_table_id_fk",
|
||||||
|
"tableFrom": "sessions_table",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"has_downloaded_restic_password": {
|
||||||
|
"name": "has_downloaded_restic_password",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_table_username_unique": {
|
||||||
|
"name": "users_table_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"volumes_table": {
|
||||||
|
"name": "volumes_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'unmounted'"
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_health_check": {
|
||||||
|
"name": "last_health_check",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auto_remount": {
|
||||||
|
"name": "auto_remount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"volumes_table_name_unique": {
|
||||||
|
"name": "volumes_table_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,13 @@
|
|||||||
"when": 1762095226041,
|
"when": 1762095226041,
|
||||||
"tag": "0009_little_adam_warlock",
|
"tag": "0009_little_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1762610065889,
|
||||||
|
"tag": "0010_perfect_proemial_gods",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,11 +15,13 @@
|
|||||||
"dockerode": "^4.0.8",
|
"dockerode": "^4.0.8",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"drizzle-orm": "^0.44.6",
|
"drizzle-orm": "^0.44.6",
|
||||||
|
"es-toolkit": "^1.41.0",
|
||||||
"hono": "^4.9.2",
|
"hono": "^4.9.2",
|
||||||
"hono-openapi": "^1.1.0",
|
"hono-openapi": "^1.1.0",
|
||||||
"http-errors-enhanced": "^3.0.2",
|
"http-errors-enhanced": "^3.0.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"tiny-typed-emitter": "^2.1.0",
|
||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
77
apps/server/src/core/capabilities.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import Docker from "dockerode";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export type SystemCapabilities = {
|
||||||
|
docker: boolean;
|
||||||
|
hostProc: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let capabilitiesPromise: Promise<SystemCapabilities> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current system capabilities.
|
||||||
|
* On first call, detects all capabilities and caches the promise.
|
||||||
|
* Subsequent calls return the same cached promise, ensuring detection only happens once.
|
||||||
|
*/
|
||||||
|
export async function getCapabilities(): Promise<SystemCapabilities> {
|
||||||
|
if (capabilitiesPromise === null) {
|
||||||
|
// Start detection and cache the promise
|
||||||
|
capabilitiesPromise = detectCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilitiesPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects which optional capabilities are available in the current environment
|
||||||
|
*/
|
||||||
|
async function detectCapabilities(): Promise<SystemCapabilities> {
|
||||||
|
return {
|
||||||
|
docker: await detectDocker(),
|
||||||
|
hostProc: await detectHostProc(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if Docker is available by:
|
||||||
|
* 1. Checking if /var/run/docker.sock exists and is accessible
|
||||||
|
* 2. Attempting to ping the Docker daemon
|
||||||
|
*/
|
||||||
|
async function detectDocker(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access("/var/run/docker.sock");
|
||||||
|
|
||||||
|
const docker = new Docker();
|
||||||
|
await docker.ping();
|
||||||
|
|
||||||
|
logger.info("Docker capability: enabled");
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
logger.warn(
|
||||||
|
"Docker capability: disabled. " +
|
||||||
|
"To enable: mount /var/run/docker.sock and /run/docker/plugins in docker-compose.yml",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if host proc is available by attempting to access /host/proc/1/ns/mnt
|
||||||
|
* This allows using nsenter to execute mount commands in the host namespace
|
||||||
|
*/
|
||||||
|
async function detectHostProc(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access("/host/proc/1/ns/mnt");
|
||||||
|
|
||||||
|
logger.info("Host proc capability: enabled");
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
logger.warn(
|
||||||
|
"Host proc capability: disabled. " +
|
||||||
|
"To enable: mount /proc:/host/proc:ro in docker-compose.yml. " +
|
||||||
|
"Mounts will be executed in container namespace instead of host namespace.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const OPERATION_TIMEOUT = 5000;
|
export const OPERATION_TIMEOUT = 5000;
|
||||||
export const VOLUME_MOUNT_BASE = "/var/lib/ironmount";
|
export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
|
||||||
export const REPOSITORY_BASE = "/var/lib/repositories";
|
export const REPOSITORY_BASE = "/var/lib/ironmount/repositories";
|
||||||
export const DATABASE_URL = "/data/ironmount.db";
|
export const DATABASE_URL = "/var/lib/ironmount/data/ironmount.db";
|
||||||
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";
|
export const RESTIC_PASS_FILE = "/var/lib/ironmount/data/restic.pass";
|
||||||
|
|||||||
37
apps/server/src/core/events.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { TypedEmitter } from "tiny-typed-emitter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payloads for the SSE system
|
||||||
|
*/
|
||||||
|
interface ServerEvents {
|
||||||
|
"backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void;
|
||||||
|
"backup:progress": (data: {
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
seconds_elapsed: number;
|
||||||
|
percent_done: number;
|
||||||
|
total_files: number;
|
||||||
|
files_done: number;
|
||||||
|
total_bytes: number;
|
||||||
|
bytes_done: number;
|
||||||
|
current_files: string[];
|
||||||
|
}) => void;
|
||||||
|
"backup:completed": (data: {
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
status: "success" | "error" | "stopped";
|
||||||
|
}) => void;
|
||||||
|
"volume:mounted": (data: { volumeName: string }) => void;
|
||||||
|
"volume:unmounted": (data: { volumeName: string }) => void;
|
||||||
|
"volume:updated": (data: { volumeName: string }) => void;
|
||||||
|
"volume:status_changed": (data: { volumeName: string; status: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global event emitter for server-side events
|
||||||
|
* Use this to emit events that should be broadcasted to connected clients via SSE
|
||||||
|
*/
|
||||||
|
export const serverEvents = new EventEmitter() as TypedEmitter<ServerEvents>;
|
||||||
@@ -5,6 +5,9 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
|
|||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { DATABASE_URL } from "../core/constants";
|
import { DATABASE_URL } from "../core/constants";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(DATABASE_URL), { recursive: true });
|
||||||
|
|
||||||
const sqlite = new Database(DATABASE_URL);
|
const sqlite = new Database(DATABASE_URL);
|
||||||
sqlite.run("PRAGMA foreign_keys = ON;");
|
sqlite.run("PRAGMA foreign_keys = ON;");
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const usersTable = sqliteTable("users_table", {
|
|||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
username: text().notNull().unique(),
|
username: text().notNull().unique(),
|
||||||
passwordHash: text("password_hash").notNull(),
|
passwordHash: text("password_hash").notNull(),
|
||||||
|
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
@@ -88,7 +89,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
|||||||
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||||
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
|
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||||
lastBackupAt: int("last_backup_at", { mode: "number" }),
|
lastBackupAt: int("last_backup_at", { mode: "number" }),
|
||||||
lastBackupStatus: text("last_backup_status").$type<"success" | "error">(),
|
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress">(),
|
||||||
lastBackupError: text("last_backup_error"),
|
lastBackupError: text("last_backup_error"),
|
||||||
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import { Hono } from "hono";
|
|||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { logger as honoLogger } from "hono/logger";
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { openAPIRouteHandler } from "hono-openapi";
|
import { openAPIRouteHandler } from "hono-openapi";
|
||||||
|
import { getCapabilities } from "./core/capabilities";
|
||||||
import { runDbMigrations } from "./db/db";
|
import { runDbMigrations } from "./db/db";
|
||||||
import { authController } from "./modules/auth/auth.controller";
|
import { authController } from "./modules/auth/auth.controller";
|
||||||
import { requireAuth } from "./modules/auth/auth.middleware";
|
import { requireAuth } from "./modules/auth/auth.middleware";
|
||||||
import { driverController } from "./modules/driver/driver.controller";
|
import { driverController } from "./modules/driver/driver.controller";
|
||||||
import { startup } from "./modules/lifecycle/startup";
|
import { startup } from "./modules/lifecycle/startup";
|
||||||
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
||||||
|
import { systemController } from "./modules/system/system.controller";
|
||||||
import { volumeController } from "./modules/volumes/volume.controller";
|
import { volumeController } from "./modules/volumes/volume.controller";
|
||||||
import { backupScheduleController } from "./modules/backups/backups.controller";
|
import { backupScheduleController } from "./modules/backups/backups.controller";
|
||||||
|
import { eventsController } from "./modules/events/events.controller";
|
||||||
import { handleServiceError } from "./utils/errors";
|
import { handleServiceError } from "./utils/errors";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
|
|
||||||
@@ -41,7 +44,10 @@ const app = new Hono()
|
|||||||
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
||||||
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
|
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
|
||||||
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
|
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
|
||||||
|
.route("/api/v1/system", systemController.use(requireAuth))
|
||||||
|
.route("/api/v1/events", eventsController.use(requireAuth))
|
||||||
.get("/assets/*", serveStatic({ root: "./assets/frontend" }))
|
.get("/assets/*", serveStatic({ root: "./assets/frontend" }))
|
||||||
|
.get("/images/*", serveStatic({ root: "./assets/frontend" }))
|
||||||
.get("*", serveStatic({ path: "./assets/frontend/index.html" }));
|
.get("*", serveStatic({ path: "./assets/frontend/index.html" }));
|
||||||
|
|
||||||
app.get("/api/v1/openapi.json", generalDescriptor(app));
|
app.get("/api/v1/openapi.json", generalDescriptor(app));
|
||||||
@@ -59,16 +65,27 @@ app.onError((err, c) => {
|
|||||||
return c.json({ message }, status);
|
return c.json({ message }, status);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
runDbMigrations();
|
||||||
|
|
||||||
|
const { docker } = await getCapabilities();
|
||||||
|
|
||||||
|
if (docker) {
|
||||||
const socketPath = "/run/docker/plugins/ironmount.sock";
|
const socketPath = "/run/docker/plugins/ironmount.sock";
|
||||||
|
|
||||||
|
try {
|
||||||
await fs.mkdir("/run/docker/plugins", { recursive: true });
|
await fs.mkdir("/run/docker/plugins", { recursive: true });
|
||||||
runDbMigrations();
|
|
||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
unix: socketPath,
|
unix: socketPath,
|
||||||
fetch: driver.fetch,
|
fetch: driver.fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.info(`Docker volume plugin server running at ${socketPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to start Docker volume plugin server: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
port: 4096,
|
port: 4096,
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
@@ -76,6 +93,6 @@ Bun.serve({
|
|||||||
|
|
||||||
startup();
|
startup();
|
||||||
|
|
||||||
logger.info(`Server is running at http://localhost:4096 and unix socket at ${socketPath}`);
|
logger.info(`Server is running at http://localhost:4096`);
|
||||||
|
|
||||||
export type AppType = typeof app;
|
export type AppType = typeof app;
|
||||||
|
|||||||
@@ -16,7 +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) => getVolumePath(v.name) === mount.mountPoint);
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === 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);
|
await executeUnmount(mount.mountPoint);
|
||||||
@@ -33,8 +33,8 @@ export class CleanupDanglingMountsJob extends Job {
|
|||||||
const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
|
const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
|
||||||
|
|
||||||
for (const dir of allIronmountDirs) {
|
for (const dir of allIronmountDirs) {
|
||||||
const volumePath = getVolumePath(dir);
|
const volumePath = `${VOLUME_MOUNT_BASE}/${dir}/_data`;
|
||||||
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === volumePath);
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === 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...`);
|
||||||
|
|||||||
26
apps/server/src/jobs/repository-healthchecks.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Job } from "../core/scheduler";
|
||||||
|
import { repositoriesService } from "../modules/repositories/repositories.service";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { db } from "../db/db";
|
||||||
|
import { eq, or } from "drizzle-orm";
|
||||||
|
import { repositoriesTable } from "../db/schema";
|
||||||
|
|
||||||
|
export class RepositoryHealthCheckJob extends Job {
|
||||||
|
async run() {
|
||||||
|
logger.debug("Running health check for all repositories...");
|
||||||
|
|
||||||
|
const repositories = await db.query.repositoriesTable.findMany({
|
||||||
|
where: or(eq(repositoriesTable.status, "healthy"), eq(repositoriesTable.status, "error")),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const repository of repositories) {
|
||||||
|
try {
|
||||||
|
await repositoriesService.checkHealth(repository.id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Health check failed for repository ${repository.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { done: true, timestamp: new Date() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { validator } from "hono-openapi";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||||
import {
|
import {
|
||||||
|
changePasswordBodySchema,
|
||||||
|
changePasswordDto,
|
||||||
getMeDto,
|
getMeDto,
|
||||||
getStatusDto,
|
getStatusDto,
|
||||||
loginBodySchema,
|
loginBodySchema,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
logoutDto,
|
logoutDto,
|
||||||
registerBodySchema,
|
registerBodySchema,
|
||||||
registerDto,
|
registerDto,
|
||||||
|
type ChangePasswordDto,
|
||||||
type GetMeDto,
|
type GetMeDto,
|
||||||
type GetStatusDto,
|
type GetStatusDto,
|
||||||
type LoginDto,
|
type LoginDto,
|
||||||
@@ -40,7 +43,15 @@ export const authController = new Hono()
|
|||||||
});
|
});
|
||||||
|
|
||||||
return c.json<RegisterDto>(
|
return c.json<RegisterDto>(
|
||||||
{ success: true, message: "User registered successfully", user: { id: user.id, username: user.username } },
|
{
|
||||||
|
success: true,
|
||||||
|
message: "User registered successfully",
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
201,
|
201,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -61,7 +72,11 @@ export const authController = new Hono()
|
|||||||
return c.json<LoginDto>({
|
return c.json<LoginDto>({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Login successful",
|
message: "Login successful",
|
||||||
user: { id: user.id, username: user.username },
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
||||||
@@ -100,4 +115,27 @@ export const authController = new Hono()
|
|||||||
.get("/status", getStatusDto, async (c) => {
|
.get("/status", getStatusDto, async (c) => {
|
||||||
const hasUsers = await authService.hasUsers();
|
const hasUsers = await authService.hasUsers();
|
||||||
return c.json<GetStatusDto>({ hasUsers });
|
return c.json<GetStatusDto>({ hasUsers });
|
||||||
|
})
|
||||||
|
.post("/change-password", changePasswordDto, validator("json", changePasswordBodySchema), async (c) => {
|
||||||
|
const sessionId = getCookie(c, COOKIE_NAME);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await authService.verifySession(sessionId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||||
|
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.changePassword(session.user.id, body.currentPassword, body.newPassword);
|
||||||
|
return c.json<ChangePasswordDto>({ success: true, message: "Password changed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const loginResponseSchema = type({
|
|||||||
user: type({
|
user: type({
|
||||||
id: "number",
|
id: "number",
|
||||||
username: "string",
|
username: "string",
|
||||||
|
hasDownloadedResticPassword: "boolean",
|
||||||
}).optional(),
|
}).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,9 +35,6 @@ export const loginDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
401: {
|
|
||||||
description: "Invalid credentials",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,9 +53,6 @@ export const registerDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: {
|
|
||||||
description: "Invalid request or username already exists",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,5 +120,34 @@ export const getStatusDto = describeRoute({
|
|||||||
|
|
||||||
export type GetStatusDto = typeof statusResponseSchema.infer;
|
export type GetStatusDto = typeof statusResponseSchema.infer;
|
||||||
|
|
||||||
|
export const changePasswordBodySchema = type({
|
||||||
|
currentPassword: "string>0",
|
||||||
|
newPassword: "string>7",
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePasswordResponseSchema = type({
|
||||||
|
success: "boolean",
|
||||||
|
message: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changePasswordDto = describeRoute({
|
||||||
|
description: "Change current user password",
|
||||||
|
operationId: "changePassword",
|
||||||
|
tags: ["Auth"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Password changed successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(changePasswordResponseSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
|
||||||
|
|
||||||
export type LoginBody = typeof loginBodySchema.infer;
|
export type LoginBody = typeof loginBodySchema.infer;
|
||||||
export type RegisterBody = typeof registerBodySchema.infer;
|
export type RegisterBody = typeof registerBodySchema.infer;
|
||||||
|
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ declare module "hono" {
|
|||||||
user: {
|
user: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,15 @@ export class AuthService {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { user: { id: user.id, username: user.username, createdAt: user.createdAt }, sessionId };
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +78,11 @@ export class AuthService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
user: { id: user.id, username: user.username },
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||||
|
},
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -109,6 +121,7 @@ export class AuthService {
|
|||||||
user: {
|
user: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
username: session.user.username,
|
username: session.user.username,
|
||||||
|
hasDownloadedResticPassword: session.user.hasDownloadedResticPassword,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: session.session.id,
|
id: session.session.id,
|
||||||
@@ -134,6 +147,33 @@ export class AuthService {
|
|||||||
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
||||||
return !!user;
|
return !!user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password for a user
|
||||||
|
*/
|
||||||
|
async changePassword(userId: number, currentPassword: string, newPassword: string) {
|
||||||
|
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await Bun.password.verify(currentPassword, user.passwordHash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error("Current password is incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPasswordHash = await Bun.password.hash(newPassword, {
|
||||||
|
algorithm: "argon2id",
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.update(usersTable).set({ passwordHash: newPasswordHash }).where(eq(usersTable.id, userId));
|
||||||
|
|
||||||
|
logger.info(`Password changed for user: ${user.username}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = new AuthService();
|
export const authService = new AuthService();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type VolumeBackend = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
||||||
const path = getVolumePath(volume.name);
|
const path = getVolumePath(volume);
|
||||||
|
|
||||||
switch (volume.config.backend) {
|
switch (volume.config.backend) {
|
||||||
case "nfs": {
|
case "nfs": {
|
||||||
|
|||||||
@@ -5,10 +5,26 @@ import { toMessage } from "../../../utils/errors";
|
|||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
import type { VolumeBackend } from "../backend";
|
import type { VolumeBackend } from "../backend";
|
||||||
|
|
||||||
const mount = async (_config: BackendConfig, path: string) => {
|
const mount = async (config: BackendConfig, _volumePath: string) => {
|
||||||
logger.info("Mounting directory volume...", path);
|
if (config.backend !== "directory") {
|
||||||
await fs.mkdir(path, { recursive: true });
|
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Mounting directory volume from:", config.path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(config.path);
|
||||||
|
const stats = await fs.stat(config.path);
|
||||||
|
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return { status: BACKEND_STATUS.error, error: "Path is not a directory" };
|
||||||
|
}
|
||||||
|
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to mount directory volume:", error);
|
||||||
|
return { status: BACKEND_STATUS.error, error: toMessage(error) };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unmount = async () => {
|
const unmount = async () => {
|
||||||
@@ -16,12 +32,16 @@ const unmount = async () => {
|
|||||||
return { status: BACKEND_STATUS.unmounted };
|
return { status: BACKEND_STATUS.unmounted };
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkHealth = async (path: string) => {
|
const checkHealth = async (config: BackendConfig) => {
|
||||||
|
if (config.backend !== "directory") {
|
||||||
|
return { status: BACKEND_STATUS.error, error: "Invalid backend type" };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(path);
|
await fs.access(config.path);
|
||||||
|
|
||||||
// Try to create a temporary file to ensure write access
|
// Try to create a temporary file to ensure write access
|
||||||
const tempFilePath = npath.join(path, `.healthcheck-${Date.now()}`);
|
const tempFilePath = npath.join(config.path, `.healthcheck-${Date.now()}`);
|
||||||
await fs.writeFile(tempFilePath, "healthcheck");
|
await fs.writeFile(tempFilePath, "healthcheck");
|
||||||
await fs.unlink(tempFilePath);
|
await fs.unlink(tempFilePath);
|
||||||
|
|
||||||
@@ -32,8 +52,8 @@ const checkHealth = async (path: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const makeDirectoryBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
export const makeDirectoryBackend = (config: BackendConfig, volumePath: string): VolumeBackend => ({
|
||||||
mount: () => mount(config, path),
|
mount: () => mount(config, volumePath),
|
||||||
unmount,
|
unmount,
|
||||||
checkHealth: () => checkHealth(path),
|
checkHealth: () => checkHealth(config),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: 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(path);
|
const { status } = await checkHealth(path, config.readOnly ?? false);
|
||||||
if (status === "mounted") {
|
if (status === "mounted") {
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||||||
|
|
||||||
const source = `${config.server}:${config.exportPath}`;
|
const source = `${config.server}:${config.exportPath}`;
|
||||||
const options = [`vers=${config.version}`, `port=${config.port}`];
|
const options = [`vers=${config.version}`, `port=${config.port}`];
|
||||||
|
if (config.readOnly) {
|
||||||
|
options.push("ro");
|
||||||
|
}
|
||||||
const args = ["-t", "nfs", "-o", options.join(","), source, path];
|
const args = ["-t", "nfs", "-o", options.join(","), source, path];
|
||||||
|
|
||||||
logger.debug(`Mounting volume ${path}...`);
|
logger.debug(`Mounting volume ${path}...`);
|
||||||
@@ -84,7 +87,7 @@ const unmount = async (path: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkHealth = async (path: string) => {
|
const checkHealth = async (path: string, readOnly: boolean) => {
|
||||||
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);
|
||||||
@@ -95,7 +98,9 @@ const checkHealth = async (path: string) => {
|
|||||||
throw new Error(`Path ${path} is not mounted as NFS.`);
|
throw new Error(`Path ${path} is not mounted as NFS.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!readOnly) {
|
||||||
await createTestFile(path);
|
await createTestFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`NFS volume at ${path} is healthy and mounted.`);
|
logger.debug(`NFS volume at ${path} is healthy and mounted.`);
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
@@ -112,5 +117,5 @@ const checkHealth = async (path: string) => {
|
|||||||
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
mount: () => mount(config, path),
|
mount: () => mount(config, path),
|
||||||
unmount: () => unmount(path),
|
unmount: () => unmount(path),
|
||||||
checkHealth: () => checkHealth(path),
|
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: 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(path);
|
const { status } = await checkHealth(path, config.readOnly ?? false);
|
||||||
if (status === "mounted") {
|
if (status === "mounted") {
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,10 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||||||
options.push(`domain=${config.domain}`);
|
options.push(`domain=${config.domain}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.readOnly) {
|
||||||
|
options.push("ro");
|
||||||
|
}
|
||||||
|
|
||||||
const args = ["-t", "cifs", "-o", options.join(","), source, path];
|
const args = ["-t", "cifs", "-o", options.join(","), source, path];
|
||||||
|
|
||||||
logger.debug(`Mounting SMB volume ${path}...`);
|
logger.debug(`Mounting SMB volume ${path}...`);
|
||||||
@@ -96,7 +100,7 @@ const unmount = async (path: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkHealth = async (path: string) => {
|
const checkHealth = async (path: string, readOnly: boolean) => {
|
||||||
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);
|
||||||
@@ -107,7 +111,9 @@ const checkHealth = async (path: string) => {
|
|||||||
throw new Error(`Path ${path} is not mounted as CIFS/SMB.`);
|
throw new Error(`Path ${path} is not mounted as CIFS/SMB.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!readOnly) {
|
||||||
await createTestFile(path);
|
await createTestFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`SMB volume at ${path} is healthy and mounted.`);
|
logger.debug(`SMB volume at ${path} is healthy and mounted.`);
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
@@ -124,5 +130,5 @@ const checkHealth = async (path: string) => {
|
|||||||
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
mount: () => mount(config, path),
|
mount: () => mount(config, path),
|
||||||
unmount: () => unmount(path),
|
unmount: () => unmount(path),
|
||||||
checkHealth: () => checkHealth(path),
|
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,24 +2,24 @@ import { execFile as execFileCb } from "node:child_process";
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as npath from "node:path";
|
import * as npath from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { getCapabilities } from "../../../core/capabilities";
|
||||||
import { OPERATION_TIMEOUT } 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 { access, constants } from "node:fs/promises";
|
|
||||||
|
|
||||||
const execFile = promisify(execFileCb);
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
export const executeMount = async (args: string[]): Promise<void> => {
|
export const executeMount = async (args: string[]): Promise<void> => {
|
||||||
|
const capabilities = await getCapabilities();
|
||||||
let stderr: string | undefined;
|
let stderr: string | undefined;
|
||||||
|
|
||||||
try {
|
if (capabilities.hostProc) {
|
||||||
await access("/host/proc", constants.F_OK);
|
|
||||||
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], {
|
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "mount", ...args], {
|
||||||
timeout: OPERATION_TIMEOUT,
|
timeout: OPERATION_TIMEOUT,
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
});
|
});
|
||||||
stderr = result.stderr;
|
stderr = result.stderr;
|
||||||
} catch (_) {
|
} else {
|
||||||
const result = await execFile("mount", args, {
|
const result = await execFile("mount", args, {
|
||||||
timeout: OPERATION_TIMEOUT,
|
timeout: OPERATION_TIMEOUT,
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
@@ -33,16 +33,16 @@ export const executeMount = async (args: string[]): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const executeUnmount = async (path: string): Promise<void> => {
|
export const executeUnmount = async (path: string): Promise<void> => {
|
||||||
|
const capabilities = await getCapabilities();
|
||||||
let stderr: string | undefined;
|
let stderr: string | undefined;
|
||||||
|
|
||||||
try {
|
if (capabilities.hostProc) {
|
||||||
await access("/host/proc", constants.F_OK);
|
|
||||||
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], {
|
const result = await execFile("nsenter", ["--mount=/host/proc/1/ns/mnt", "umount", "-l", "-f", path], {
|
||||||
timeout: OPERATION_TIMEOUT,
|
timeout: OPERATION_TIMEOUT,
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
});
|
});
|
||||||
stderr = result.stderr;
|
stderr = result.stderr;
|
||||||
} catch (_) {
|
} else {
|
||||||
const result = await execFile("umount", ["-l", "-f", path], {
|
const result = await execFile("umount", ["-l", "-f", path], {
|
||||||
timeout: OPERATION_TIMEOUT,
|
timeout: OPERATION_TIMEOUT,
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||||||
return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." };
|
return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await checkHealth(path);
|
const { status } = await checkHealth(path, config.readOnly ?? false);
|
||||||
if (status === "mounted") {
|
if (status === "mounted") {
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||||||
const port = config.port !== defaultPort ? `:${config.port}` : "";
|
const port = config.port !== defaultPort ? `:${config.port}` : "";
|
||||||
const source = `${protocol}://${config.server}${port}${config.path}`;
|
const source = `${protocol}://${config.server}${port}${config.path}`;
|
||||||
|
|
||||||
const options = ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
|
const options = config.readOnly
|
||||||
|
? ["uid=1000", "gid=1000", "file_mode=0444", "dir_mode=0555", "ro"]
|
||||||
|
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
|
||||||
|
|
||||||
if (config.username && config.password) {
|
if (config.username && config.password) {
|
||||||
const secretsFile = "/etc/davfs2/secrets";
|
const secretsFile = "/etc/davfs2/secrets";
|
||||||
@@ -132,7 +134,7 @@ const unmount = async (path: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkHealth = async (path: string) => {
|
const checkHealth = async (path: string, readOnly: boolean) => {
|
||||||
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);
|
||||||
@@ -143,7 +145,9 @@ const checkHealth = async (path: string) => {
|
|||||||
throw new Error(`Path ${path} is not mounted as WebDAV.`);
|
throw new Error(`Path ${path} is not mounted as WebDAV.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!readOnly) {
|
||||||
await createTestFile(path);
|
await createTestFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`WebDAV volume at ${path} is healthy and mounted.`);
|
logger.debug(`WebDAV volume at ${path} is healthy and mounted.`);
|
||||||
return { status: BACKEND_STATUS.mounted };
|
return { status: BACKEND_STATUS.mounted };
|
||||||
@@ -160,5 +164,5 @@ const checkHealth = async (path: string) => {
|
|||||||
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({
|
||||||
mount: () => mount(config, path),
|
mount: () => mount(config, path),
|
||||||
unmount: () => unmount(path),
|
unmount: () => unmount(path),
|
||||||
checkHealth: () => checkHealth(path),
|
checkHealth: () => checkHealth(path, config.readOnly ?? false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getBackupScheduleForVolumeDto,
|
getBackupScheduleForVolumeDto,
|
||||||
listBackupSchedulesDto,
|
listBackupSchedulesDto,
|
||||||
runBackupNowDto,
|
runBackupNowDto,
|
||||||
|
stopBackupDto,
|
||||||
updateBackupScheduleDto,
|
updateBackupScheduleDto,
|
||||||
updateBackupScheduleBody,
|
updateBackupScheduleBody,
|
||||||
type CreateBackupScheduleDto,
|
type CreateBackupScheduleDto,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
type GetBackupScheduleForVolumeResponseDto,
|
type GetBackupScheduleForVolumeResponseDto,
|
||||||
type ListBackupSchedulesResponseDto,
|
type ListBackupSchedulesResponseDto,
|
||||||
type RunBackupNowDto,
|
type RunBackupNowDto,
|
||||||
|
type StopBackupDto,
|
||||||
type UpdateBackupScheduleDto,
|
type UpdateBackupScheduleDto,
|
||||||
} from "./backups.dto";
|
} from "./backups.dto";
|
||||||
import { backupsService } from "./backups.service";
|
import { backupsService } from "./backups.service";
|
||||||
@@ -69,4 +71,11 @@ export const backupScheduleController = new Hono()
|
|||||||
});
|
});
|
||||||
|
|
||||||
return c.json<RunBackupNowDto>({ success: true }, 200);
|
return c.json<RunBackupNowDto>({ success: true }, 200);
|
||||||
|
})
|
||||||
|
.post("/:scheduleId/stop", stopBackupDto, async (c) => {
|
||||||
|
const scheduleId = c.req.param("scheduleId");
|
||||||
|
|
||||||
|
await backupsService.stopBackup(Number(scheduleId));
|
||||||
|
|
||||||
|
return c.json<StopBackupDto>({ success: true }, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const backupScheduleSchema = type({
|
|||||||
excludePatterns: "string[] | null",
|
excludePatterns: "string[] | null",
|
||||||
includePatterns: "string[] | null",
|
includePatterns: "string[] | null",
|
||||||
lastBackupAt: "number | null",
|
lastBackupAt: "number | null",
|
||||||
lastBackupStatus: "'success' | 'error' | null",
|
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
|
||||||
lastBackupError: "string | null",
|
lastBackupError: "string | null",
|
||||||
nextBackupAt: "number | null",
|
nextBackupAt: "number | null",
|
||||||
createdAt: "number",
|
createdAt: "number",
|
||||||
@@ -223,3 +223,31 @@ export const runBackupNowDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a running backup
|
||||||
|
*/
|
||||||
|
export const stopBackupResponse = type({
|
||||||
|
success: "boolean",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StopBackupDto = typeof stopBackupResponse.infer;
|
||||||
|
|
||||||
|
export const stopBackupDto = describeRoute({
|
||||||
|
description: "Stop a backup that is currently in progress",
|
||||||
|
operationId: "stopBackup",
|
||||||
|
tags: ["Backups"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Backup stopped successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(stopBackupResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: "No backup is currently running for this schedule",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
import { CronExpressionParser } from "cron-parser";
|
import { CronExpressionParser } from "cron-parser";
|
||||||
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
||||||
import { db } from "../../db/db";
|
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";
|
||||||
@@ -9,6 +9,9 @@ import { logger } from "../../utils/logger";
|
|||||||
import { getVolumePath } from "../volumes/helpers";
|
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";
|
||||||
|
|
||||||
|
const runningBackups = new Map<number, AbortController>();
|
||||||
|
|
||||||
const calculateNextRun = (cronExpression: string): number => {
|
const calculateNextRun = (cronExpression: string): number => {
|
||||||
try {
|
try {
|
||||||
@@ -159,6 +162,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (schedule.lastBackupStatus === "in_progress") {
|
||||||
|
logger.info(`Backup schedule ${scheduleId} is already in progress. Skipping execution.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const volume = await db.query.volumesTable.findFirst({
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
where: eq(volumesTable.id, schedule.volumeId),
|
where: eq(volumesTable.id, schedule.volumeId),
|
||||||
});
|
});
|
||||||
@@ -181,15 +189,31 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
|
|
||||||
logger.info(`Starting backup for volume ${volume.name} to repository ${repository.name}`);
|
logger.info(`Starting backup for volume ${volume.name} to repository ${repository.name}`);
|
||||||
|
|
||||||
|
serverEvents.emit("backup:started", {
|
||||||
|
scheduleId,
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(backupSchedulesTable)
|
||||||
|
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null })
|
||||||
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
runningBackups.set(scheduleId, abortController);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const volumePath = getVolumePath(volume.name);
|
const volumePath = getVolumePath(volume);
|
||||||
|
|
||||||
const backupOptions: {
|
const backupOptions: {
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
include?: string[];
|
include?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
} = {
|
} = {
|
||||||
tags: [schedule.id.toString()],
|
tags: [schedule.id.toString()],
|
||||||
|
signal: abortController.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
|
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
|
||||||
@@ -200,7 +224,17 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
backupOptions.include = schedule.includePatterns;
|
backupOptions.include = schedule.includePatterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
await restic.backup(repository.config, volumePath, backupOptions);
|
await restic.backup(repository.config, volumePath, {
|
||||||
|
...backupOptions,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
serverEvents.emit("backup:progress", {
|
||||||
|
scheduleId,
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
...progress,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (schedule.retentionPolicy) {
|
if (schedule.retentionPolicy) {
|
||||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
|
||||||
@@ -219,6 +253,13 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
|
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
|
||||||
|
|
||||||
|
serverEvents.emit("backup:completed", {
|
||||||
|
scheduleId,
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`);
|
logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`);
|
||||||
|
|
||||||
@@ -232,7 +273,16 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
|||||||
})
|
})
|
||||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
|
serverEvents.emit("backup:completed", {
|
||||||
|
scheduleId,
|
||||||
|
volumeName: volume.name,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
status: "error",
|
||||||
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
runningBackups.delete(scheduleId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,6 +312,34 @@ const getScheduleForVolume = async (volumeId: number) => {
|
|||||||
return schedule ?? null;
|
return schedule ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopBackup = async (scheduleId: number) => {
|
||||||
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
|
where: eq(backupSchedulesTable.id, scheduleId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(backupSchedulesTable)
|
||||||
|
.set({
|
||||||
|
lastBackupStatus: "error",
|
||||||
|
lastBackupError: "Backup was stopped by user",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||||
|
|
||||||
|
const abortController = runningBackups.get(scheduleId);
|
||||||
|
if (!abortController) {
|
||||||
|
throw new ConflictError("No backup is currently running for this schedule");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Stopping backup for schedule ${scheduleId}`);
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
|
||||||
export const backupsService = {
|
export const backupsService = {
|
||||||
listSchedules,
|
listSchedules,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
@@ -271,4 +349,5 @@ export const backupsService = {
|
|||||||
executeBackup,
|
executeBackup,
|
||||||
getSchedulesToExecute,
|
getSchedulesToExecute,
|
||||||
getScheduleForVolume,
|
getScheduleForVolume,
|
||||||
|
stopBackup,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const driverController = new Hono()
|
|||||||
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: getVolumePath(volume.name),
|
Mountpoint: getVolumePath(volume),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post("/VolumeDriver.Get", async (c) => {
|
.post("/VolumeDriver.Get", async (c) => {
|
||||||
@@ -66,7 +66,7 @@ export const driverController = new Hono()
|
|||||||
return c.json({
|
return c.json({
|
||||||
Volume: {
|
Volume: {
|
||||||
Name: `im-${volume.name}`,
|
Name: `im-${volume.name}`,
|
||||||
Mountpoint: getVolumePath(volume.name),
|
Mountpoint: getVolumePath(volume),
|
||||||
Status: {},
|
Status: {},
|
||||||
},
|
},
|
||||||
Err: "",
|
Err: "",
|
||||||
@@ -77,7 +77,7 @@ export const driverController = new Hono()
|
|||||||
|
|
||||||
const res = volumes.map((volume) => ({
|
const res = volumes.map((volume) => ({
|
||||||
Name: `im-${volume.name}`,
|
Name: `im-${volume.name}`,
|
||||||
Mountpoint: getVolumePath(volume.name),
|
Mountpoint: getVolumePath(volume),
|
||||||
Status: {},
|
Status: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
101
apps/server/src/modules/events/events.controller.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { streamSSE } from "hono/streaming";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import { serverEvents } from "../../core/events";
|
||||||
|
|
||||||
|
export const eventsController = new Hono().get("/", (c) => {
|
||||||
|
logger.info("Client connected to SSE endpoint");
|
||||||
|
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
await stream.writeSSE({
|
||||||
|
data: JSON.stringify({ type: "connected", timestamp: Date.now() }),
|
||||||
|
event: "connected",
|
||||||
|
});
|
||||||
|
|
||||||
|
const onBackupStarted = (data: { scheduleId: number; volumeName: string; repositoryName: string }) => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
event: "backup:started",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBackupProgress = (data: {
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
secondsElapsed: number;
|
||||||
|
percentDone: number;
|
||||||
|
totalFiles: number;
|
||||||
|
filesDone: number;
|
||||||
|
totalBytes: number;
|
||||||
|
bytesDone: number;
|
||||||
|
currentFiles: string[];
|
||||||
|
}) => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
event: "backup:progress",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBackupCompleted = (data: {
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
status: "success" | "error" | "stopped";
|
||||||
|
}) => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
event: "backup:completed",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVolumeMounted = (data: { volumeName: string }) => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
event: "volume:mounted",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVolumeUnmounted = (data: { volumeName: string }) => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
event: "volume:unmounted",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVolumeUpdated = (data: { volumeName: string }) => {
|
||||||
|
stream.writeSSE({
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
event: "volume:updated",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
serverEvents.on("backup:started", onBackupStarted);
|
||||||
|
serverEvents.on("backup:progress", onBackupProgress);
|
||||||
|
serverEvents.on("backup:completed", onBackupCompleted);
|
||||||
|
serverEvents.on("volume:mounted", onVolumeMounted);
|
||||||
|
serverEvents.on("volume:unmounted", onVolumeUnmounted);
|
||||||
|
serverEvents.on("volume:updated", onVolumeUpdated);
|
||||||
|
|
||||||
|
let keepAlive = true;
|
||||||
|
|
||||||
|
stream.onAbort(() => {
|
||||||
|
logger.info("Client disconnected from SSE endpoint");
|
||||||
|
keepAlive = false;
|
||||||
|
serverEvents.off("backup:started", onBackupStarted);
|
||||||
|
serverEvents.off("backup:progress", onBackupProgress);
|
||||||
|
serverEvents.off("backup:completed", onBackupCompleted);
|
||||||
|
serverEvents.off("volume:mounted", onVolumeMounted);
|
||||||
|
serverEvents.off("volume:unmounted", onVolumeUnmounted);
|
||||||
|
serverEvents.off("volume:updated", onVolumeUpdated);
|
||||||
|
});
|
||||||
|
|
||||||
|
while (keepAlive) {
|
||||||
|
await stream.writeSSE({
|
||||||
|
data: JSON.stringify({ timestamp: Date.now() }),
|
||||||
|
event: "heartbeat",
|
||||||
|
});
|
||||||
|
await stream.sleep(5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { restic } from "../../utils/restic";
|
|||||||
import { volumeService } from "../volumes/volume.service";
|
import { volumeService } from "../volumes/volume.service";
|
||||||
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
|
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
|
||||||
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
||||||
|
import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks";
|
||||||
import { BackupExecutionJob } from "../../jobs/backup-execution";
|
import { BackupExecutionJob } from "../../jobs/backup-execution";
|
||||||
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export const startup = async () => {
|
|||||||
|
|
||||||
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
||||||
Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
|
Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
|
||||||
|
Scheduler.build(RepositoryHealthCheckJob).schedule("*/10 * * * *");
|
||||||
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
||||||
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createRepositoryBody,
|
createRepositoryBody,
|
||||||
createRepositoryDto,
|
createRepositoryDto,
|
||||||
deleteRepositoryDto,
|
deleteRepositoryDto,
|
||||||
|
doctorRepositoryDto,
|
||||||
getRepositoryDto,
|
getRepositoryDto,
|
||||||
getSnapshotDetailsDto,
|
getSnapshotDetailsDto,
|
||||||
listRepositoriesDto,
|
listRepositoriesDto,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
restoreSnapshotBody,
|
restoreSnapshotBody,
|
||||||
restoreSnapshotDto,
|
restoreSnapshotDto,
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
|
type DoctorRepositoryDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
type GetSnapshotDetailsDto,
|
type GetSnapshotDetailsDto,
|
||||||
type ListRepositoriesDto,
|
type ListRepositoriesDto,
|
||||||
@@ -71,6 +73,8 @@ export const repositoriesController = new Hono()
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
|
||||||
|
|
||||||
return c.json<ListSnapshotsDto>(snapshots, 200);
|
return c.json<ListSnapshotsDto>(snapshots, 200);
|
||||||
})
|
})
|
||||||
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
|
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
|
||||||
@@ -104,7 +108,7 @@ export const repositoriesController = new Hono()
|
|||||||
|
|
||||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
|
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
|
||||||
|
|
||||||
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
// c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
||||||
|
|
||||||
return c.json<ListSnapshotFilesDto>(result, 200);
|
return c.json<ListSnapshotFilesDto>(result, 200);
|
||||||
},
|
},
|
||||||
@@ -116,4 +120,11 @@ export const repositoriesController = new Hono()
|
|||||||
const result = await repositoriesService.restoreSnapshot(name, snapshotId, options);
|
const result = await repositoriesService.restoreSnapshot(name, snapshotId, options);
|
||||||
|
|
||||||
return c.json<RestoreSnapshotDto>(result, 200);
|
return c.json<RestoreSnapshotDto>(result, 200);
|
||||||
|
})
|
||||||
|
.post("/:name/doctor", doctorRepositoryDto, async (c) => {
|
||||||
|
const { name } = c.req.param();
|
||||||
|
|
||||||
|
const result = await repositoriesService.doctorRepository(name);
|
||||||
|
|
||||||
|
return c.json<DoctorRepositoryDto>(result, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -271,3 +271,37 @@ export const restoreSnapshotDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doctor a repository (unlock, check, repair)
|
||||||
|
*/
|
||||||
|
export const doctorStepSchema = type({
|
||||||
|
step: "string",
|
||||||
|
success: "boolean",
|
||||||
|
output: "string | null",
|
||||||
|
error: "string | null",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const doctorRepositoryResponse = type({
|
||||||
|
success: "boolean",
|
||||||
|
steps: doctorStepSchema.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DoctorRepositoryDto = typeof doctorRepositoryResponse.infer;
|
||||||
|
|
||||||
|
export const doctorRepositoryDto = describeRoute({
|
||||||
|
description:
|
||||||
|
"Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.",
|
||||||
|
tags: ["Repositories"],
|
||||||
|
operationId: "doctorRepository",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Doctor operation completed",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(doctorRepositoryResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { repositoriesTable, volumesTable } from "../../db/schema";
|
import { repositoriesTable } from "../../db/schema";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
@@ -202,6 +202,112 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkHealth = async (repositoryId: string) => {
|
||||||
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: eq(repositoriesTable.id, repositoryId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, status } = await restic
|
||||||
|
.snapshots(repository.config)
|
||||||
|
.then(() => ({ error: null, status: "healthy" as const }))
|
||||||
|
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(repositoriesTable)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
lastError: error,
|
||||||
|
})
|
||||||
|
.where(eq(repositoriesTable.id, repository.id));
|
||||||
|
|
||||||
|
return { status, lastError: error };
|
||||||
|
};
|
||||||
|
|
||||||
|
const doctorRepository = async (name: string) => {
|
||||||
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: eq(repositoriesTable.name, name),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: Array<{ step: string; success: boolean; output: string | null; error: string | null }> = [];
|
||||||
|
|
||||||
|
const unlockResult = await restic.unlock(repository.config).then(
|
||||||
|
(result) => ({ success: true, message: result.message, error: null }),
|
||||||
|
(error) => ({ success: false, message: null, error: toMessage(error) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "unlock",
|
||||||
|
success: unlockResult.success,
|
||||||
|
output: unlockResult.message,
|
||||||
|
error: unlockResult.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkResult = await restic.check(repository.config, { readData: false }).then(
|
||||||
|
(result) => result,
|
||||||
|
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "check",
|
||||||
|
success: checkResult.success,
|
||||||
|
output: checkResult.output,
|
||||||
|
error: checkResult.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkResult.hasErrors) {
|
||||||
|
const repairResult = await restic.repairIndex(repository.config).then(
|
||||||
|
(result) => ({ success: true, output: result.output, error: null }),
|
||||||
|
(error) => ({ success: false, output: null, error: toMessage(error) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "repair_index",
|
||||||
|
success: repairResult.success,
|
||||||
|
output: repairResult.output,
|
||||||
|
error: repairResult.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recheckResult = await restic.check(repository.config, { readData: false }).then(
|
||||||
|
(result) => result,
|
||||||
|
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
step: "recheck",
|
||||||
|
success: recheckResult.success,
|
||||||
|
output: recheckResult.output,
|
||||||
|
error: recheckResult.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSuccessful = steps.every((s) => s.success);
|
||||||
|
|
||||||
|
console.log("Doctor steps:", steps);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(repositoriesTable)
|
||||||
|
.set({
|
||||||
|
status: allSuccessful ? "healthy" : "error",
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
lastError: allSuccessful ? null : steps.find((s) => !s.success)?.error,
|
||||||
|
})
|
||||||
|
.where(eq(repositoriesTable.id, repository.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: allSuccessful,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const repositoriesService = {
|
export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
@@ -211,4 +317,6 @@ export const repositoriesService = {
|
|||||||
listSnapshotFiles,
|
listSnapshotFiles,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
getSnapshotDetails,
|
getSnapshotDetails,
|
||||||
|
checkHealth,
|
||||||
|
doctorRepository,
|
||||||
};
|
};
|
||||||
|
|||||||
57
apps/server/src/modules/system/system.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { validator } from "hono-openapi";
|
||||||
|
import {
|
||||||
|
downloadResticPasswordBodySchema,
|
||||||
|
downloadResticPasswordDto,
|
||||||
|
systemInfoDto,
|
||||||
|
type SystemInfoDto,
|
||||||
|
} from "./system.dto";
|
||||||
|
import { systemService } from "./system.service";
|
||||||
|
import { requireAuth } from "../auth/auth.middleware";
|
||||||
|
import { RESTIC_PASS_FILE } from "../../core/constants";
|
||||||
|
import { db } from "../../db/db";
|
||||||
|
import { usersTable } from "../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const systemController = new Hono()
|
||||||
|
.get("/info", systemInfoDto, async (c) => {
|
||||||
|
const info = await systemService.getSystemInfo();
|
||||||
|
|
||||||
|
return c.json<SystemInfoDto>(info, 200);
|
||||||
|
})
|
||||||
|
.post(
|
||||||
|
"/restic-password",
|
||||||
|
downloadResticPasswordDto,
|
||||||
|
requireAuth,
|
||||||
|
validator("json", downloadResticPasswordBodySchema),
|
||||||
|
async (c) => {
|
||||||
|
const user = c.get("user");
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, user.id));
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return c.json({ message: "User not found" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await Bun.password.verify(body.password, dbUser.passwordHash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return c.json({ message: "Incorrect password" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = Bun.file(RESTIC_PASS_FILE);
|
||||||
|
const content = await file.text();
|
||||||
|
|
||||||
|
await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, user.id));
|
||||||
|
|
||||||
|
c.header("Content-Type", "text/plain");
|
||||||
|
c.header("Content-Disposition", 'attachment; filename="restic.pass"');
|
||||||
|
|
||||||
|
return c.text(content);
|
||||||
|
} catch (_error) {
|
||||||
|
return c.json({ message: "Failed to read Restic password file" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
48
apps/server/src/modules/system/system.dto.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { type } from "arktype";
|
||||||
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
|
export const capabilitiesSchema = type({
|
||||||
|
docker: "boolean",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const systemInfoResponse = type({
|
||||||
|
capabilities: capabilitiesSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SystemInfoDto = typeof systemInfoResponse.infer;
|
||||||
|
|
||||||
|
export const systemInfoDto = describeRoute({
|
||||||
|
description: "Get system information including available capabilities",
|
||||||
|
tags: ["System"],
|
||||||
|
operationId: "getSystemInfo",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "System information with enabled capabilities",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(systemInfoResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const downloadResticPasswordBodySchema = type({
|
||||||
|
password: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const downloadResticPasswordDto = describeRoute({
|
||||||
|
description: "Download the Restic password file for backup recovery. Requires password re-authentication.",
|
||||||
|
tags: ["System"],
|
||||||
|
operationId: "downloadResticPassword",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Restic password file content",
|
||||||
|
content: {
|
||||||
|
"text/plain": {
|
||||||
|
schema: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
11
apps/server/src/modules/system/system.service.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { getCapabilities } from "../../core/capabilities";
|
||||||
|
|
||||||
|
const getSystemInfo = async () => {
|
||||||
|
return {
|
||||||
|
capabilities: await getCapabilities(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const systemService = {
|
||||||
|
getSystemInfo,
|
||||||
|
};
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||||
|
import type { Volume } from "../../db/schema";
|
||||||
|
|
||||||
export const getVolumePath = (name: string) => {
|
export const getVolumePath = (volume: Volume) => {
|
||||||
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
if (volume.config.backend === "directory") {
|
||||||
|
return volume.config.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
type ListContainersDto,
|
type ListContainersDto,
|
||||||
type UpdateVolumeDto,
|
type UpdateVolumeDto,
|
||||||
type ListFilesDto,
|
type ListFilesDto,
|
||||||
|
browseFilesystemDto,
|
||||||
|
type BrowseFilesystemDto,
|
||||||
} from "./volume.dto";
|
} from "./volume.dto";
|
||||||
import { volumeService } from "./volume.service";
|
import { volumeService } from "./volume.service";
|
||||||
import { getVolumePath } from "./helpers";
|
import { getVolumePath } from "./helpers";
|
||||||
@@ -37,7 +39,7 @@ export const volumeController = new Hono()
|
|||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
path: getVolumePath(res.volume.name),
|
path: getVolumePath(res.volume),
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json<CreateVolumeDto>(response, 201);
|
return c.json<CreateVolumeDto>(response, 201);
|
||||||
@@ -61,7 +63,7 @@ export const volumeController = new Hono()
|
|||||||
const response = {
|
const response = {
|
||||||
volume: {
|
volume: {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
path: getVolumePath(res.volume.name),
|
path: getVolumePath(res.volume),
|
||||||
},
|
},
|
||||||
statfs: {
|
statfs: {
|
||||||
total: res.statfs.total ?? 0,
|
total: res.statfs.total ?? 0,
|
||||||
@@ -85,7 +87,7 @@ export const volumeController = new Hono()
|
|||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
path: getVolumePath(res.volume.name),
|
path: getVolumePath(res.volume),
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json<UpdateVolumeDto>(response, 200);
|
return c.json<UpdateVolumeDto>(response, 200);
|
||||||
@@ -118,7 +120,18 @@ export const volumeController = new Hono()
|
|||||||
path: result.path,
|
path: result.path,
|
||||||
};
|
};
|
||||||
|
|
||||||
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
|
// c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
|
||||||
|
|
||||||
return c.json<ListFilesDto>(response, 200);
|
return c.json<ListFilesDto>(response, 200);
|
||||||
|
})
|
||||||
|
.get("/filesystem/browse", browseFilesystemDto, async (c) => {
|
||||||
|
const path = c.req.query("path") || "/";
|
||||||
|
const result = await volumeService.browseFilesystem(path);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
directories: result.directories,
|
||||||
|
path: result.path,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json<BrowseFilesystemDto>(response, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -335,3 +335,39 @@ export const listFilesDto = describeRoute({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse filesystem directories
|
||||||
|
*/
|
||||||
|
export const browseFilesystemResponse = type({
|
||||||
|
directories: fileEntrySchema.array(),
|
||||||
|
path: "string",
|
||||||
|
});
|
||||||
|
export type BrowseFilesystemDto = typeof browseFilesystemResponse.infer;
|
||||||
|
|
||||||
|
export const browseFilesystemDto = describeRoute({
|
||||||
|
description: "Browse directories on the host filesystem",
|
||||||
|
operationId: "browseFilesystem",
|
||||||
|
tags: ["Volumes"],
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
in: "query",
|
||||||
|
name: "path",
|
||||||
|
required: false,
|
||||||
|
schema: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
description: "Directory path to browse (absolute path, defaults to /)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "List of directories in the specified path",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(browseFilesystemResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import Docker from "dockerode";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
|
import { getCapabilities } from "../../core/capabilities";
|
||||||
|
import { OPERATION_TIMEOUT } from "../../core/constants";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { volumesTable } from "../../db/schema";
|
import { volumesTable } from "../../db/schema";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
||||||
|
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 { getVolumePath } from "./helpers";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
|
import { serverEvents } from "../../core/events";
|
||||||
|
|
||||||
const listVolumes = async () => {
|
const listVolumes = async () => {
|
||||||
const volumes = await db.query.volumesTable.findMany({});
|
const volumes = await db.query.volumesTable.findMany({});
|
||||||
@@ -87,6 +91,10 @@ const mountVolume = async (name: string) => {
|
|||||||
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
||||||
.where(eq(volumesTable.name, name));
|
.where(eq(volumesTable.name, name));
|
||||||
|
|
||||||
|
if (status === "mounted") {
|
||||||
|
serverEvents.emit("volume:mounted", { volumeName: name });
|
||||||
|
}
|
||||||
|
|
||||||
return { error, status };
|
return { error, status };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +112,10 @@ const unmountVolume = async (name: string) => {
|
|||||||
|
|
||||||
await db.update(volumesTable).set({ status }).where(eq(volumesTable.name, name));
|
await db.update(volumesTable).set({ status }).where(eq(volumesTable.name, name));
|
||||||
|
|
||||||
|
if (status === "unmounted") {
|
||||||
|
serverEvents.emit("volume:unmounted", { volumeName: name });
|
||||||
|
}
|
||||||
|
|
||||||
return { error, status };
|
return { error, status };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +130,10 @@ const getVolume = async (name: string) => {
|
|||||||
|
|
||||||
let statfs: Partial<StatFs> = {};
|
let statfs: Partial<StatFs> = {};
|
||||||
if (volume.status === "mounted") {
|
if (volume.status === "mounted") {
|
||||||
statfs = await getStatFs(getVolumePath(name)).catch(() => ({}));
|
statfs = await withTimeout(getStatFs(getVolumePath(volume)), 1000, "getStatFs").catch((error) => {
|
||||||
|
logger.warn(`Failed to get statfs for volume ${name}: ${toMessage(error)}`);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { volume, statfs };
|
return { volume, statfs };
|
||||||
@@ -164,6 +179,8 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
|||||||
.update(volumesTable)
|
.update(volumesTable)
|
||||||
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
||||||
.where(eq(volumesTable.name, name));
|
.where(eq(volumesTable.name, name));
|
||||||
|
|
||||||
|
serverEvents.emit("volume:updated", { volumeName: name });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { volume: updated };
|
return { volume: updated };
|
||||||
@@ -212,6 +229,10 @@ const checkHealth = async (name: string) => {
|
|||||||
const backend = createVolumeBackend(volume);
|
const backend = createVolumeBackend(volume);
|
||||||
const { error, status } = await backend.checkHealth();
|
const { error, status } = await backend.checkHealth();
|
||||||
|
|
||||||
|
if (status !== volume.status) {
|
||||||
|
serverEvents.emit("volume:status_changed", { volumeName: name, status });
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(volumesTable)
|
.update(volumesTable)
|
||||||
.set({ lastHealthCheck: Date.now(), status, lastError: error ?? null })
|
.set({ lastHealthCheck: Date.now(), status, lastError: error ?? null })
|
||||||
@@ -229,6 +250,13 @@ const getContainersUsingVolume = async (name: string) => {
|
|||||||
throw new NotFoundError("Volume not found");
|
throw new NotFoundError("Volume not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { docker } = await getCapabilities();
|
||||||
|
if (!docker) {
|
||||||
|
logger.debug("Docker capability not available, returning empty containers list");
|
||||||
|
return { containers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const docker = new Docker();
|
const docker = new Docker();
|
||||||
const containers = await docker.listContainers({ all: true });
|
const containers = await docker.listContainers({ all: true });
|
||||||
|
|
||||||
@@ -249,6 +277,10 @@ const getContainersUsingVolume = async (name: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { containers: usingContainers };
|
return { containers: usingContainers };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get containers using volume: ${toMessage(error)}`);
|
||||||
|
return { containers: [] };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const listFiles = async (name: string, subPath?: string) => {
|
const listFiles = async (name: string, subPath?: string) => {
|
||||||
@@ -264,7 +296,8 @@ const listFiles = async (name: string, subPath?: string) => {
|
|||||||
throw new InternalServerError("Volume is not mounted");
|
throw new InternalServerError("Volume is not mounted");
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumePath = getVolumePath(volume.name);
|
// For directory volumes, use the configured path directly
|
||||||
|
const volumePath = getVolumePath(volume);
|
||||||
|
|
||||||
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
|
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
|
||||||
|
|
||||||
@@ -316,6 +349,48 @@ const listFiles = async (name: string, subPath?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const browseFilesystem = async (browsePath: string) => {
|
||||||
|
const normalizedPath = path.normalize(browsePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
const directories = await Promise.all(
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map(async (entry) => {
|
||||||
|
const fullPath = path.join(normalizedPath, entry.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(fullPath);
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: fullPath,
|
||||||
|
type: "directory" as const,
|
||||||
|
size: undefined,
|
||||||
|
modifiedAt: stats.mtimeMs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: fullPath,
|
||||||
|
type: "directory" as const,
|
||||||
|
size: undefined,
|
||||||
|
modifiedAt: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
directories: directories.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
path: normalizedPath,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerError(`Failed to browse filesystem: ${toMessage(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const volumeService = {
|
export const volumeService = {
|
||||||
listVolumes,
|
listVolumes,
|
||||||
createVolume,
|
createVolume,
|
||||||
@@ -328,4 +403,5 @@ export const volumeService = {
|
|||||||
checkHealth,
|
checkHealth,
|
||||||
getContainersUsingVolume,
|
getContainersUsingVolume,
|
||||||
listFiles,
|
listFiles,
|
||||||
|
browseFilesystem,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { throttle } from "es-toolkit";
|
||||||
import type { RepositoryConfig } from "@ironmount/schemas/restic";
|
import type { RepositoryConfig } from "@ironmount/schemas/restic";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
@@ -8,6 +9,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants";
|
|||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { cryptoUtils } from "./crypto";
|
import { cryptoUtils } from "./crypto";
|
||||||
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
||||||
|
import { safeSpawn } from "./spawn";
|
||||||
|
|
||||||
const backupOutputSchema = type({
|
const backupOutputSchema = type({
|
||||||
message_type: "'summary'",
|
message_type: "'summary'",
|
||||||
@@ -111,10 +113,29 @@ const init = async (config: RepositoryConfig) => {
|
|||||||
return { success: true, error: null };
|
return { success: true, error: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backupProgressSchema = type({
|
||||||
|
message_type: "'status'",
|
||||||
|
seconds_elapsed: "number",
|
||||||
|
percent_done: "number",
|
||||||
|
total_files: "number",
|
||||||
|
files_done: "number",
|
||||||
|
total_bytes: "number",
|
||||||
|
bytes_done: "number",
|
||||||
|
current_files: "string[]",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BackupProgress = typeof backupProgressSchema.infer;
|
||||||
|
|
||||||
const backup = async (
|
const backup = async (
|
||||||
config: RepositoryConfig,
|
config: RepositoryConfig,
|
||||||
source: string,
|
source: string,
|
||||||
options?: { exclude?: string[]; include?: string[]; tags?: string[] },
|
options?: {
|
||||||
|
exclude?: string[];
|
||||||
|
include?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (progress: BackupProgress) => void;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config);
|
||||||
@@ -148,28 +169,55 @@ const backup = async (
|
|||||||
|
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
|
|
||||||
await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
const logData = throttle((data: string) => {
|
||||||
const res = await $`restic ${args}`.env(env).nothrow();
|
logger.info(data.trim());
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
if (includeFile) {
|
const streamProgress = throttle((data: string) => {
|
||||||
await fs.unlink(includeFile).catch(() => {});
|
if (options?.onProgress) {
|
||||||
|
try {
|
||||||
|
const jsonData = JSON.parse(data);
|
||||||
|
const progress = backupProgressSchema(jsonData);
|
||||||
|
if (!(progress instanceof type.errors)) {
|
||||||
|
options.onProgress(progress);
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
if (res.exitCode !== 0) {
|
// Ignore JSON parse errors for non-JSON lines
|
||||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
|
||||||
throw new Error(`Restic backup failed: ${res.stderr}`);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
// res is a succession of JSON objects, we need to parse the last one which contains the summary
|
let stdout = "";
|
||||||
const stdout = res.text();
|
|
||||||
const outputLines = stdout.trim().split("\n");
|
await safeSpawn({
|
||||||
const lastLine = outputLines[outputLines.length - 1];
|
command: "restic",
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
signal: options?.signal,
|
||||||
|
onStdout: (data) => {
|
||||||
|
stdout = data;
|
||||||
|
logData(data);
|
||||||
|
|
||||||
|
if (options?.onProgress) {
|
||||||
|
streamProgress(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStderr: (error) => {
|
||||||
|
logger.error(error.trim());
|
||||||
|
},
|
||||||
|
finally: async () => {
|
||||||
|
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastLine = stdout.trim();
|
||||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||||
|
|
||||||
const result = backupOutputSchema(resSummary);
|
const result = backupOutputSchema(resSummary);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
logger.error(`Restic backup output validation failed: ${result}`);
|
logger.error(`Restic backup output validation failed: ${result}`);
|
||||||
|
|
||||||
throw new Error(`Restic backup output validation failed: ${result}`);
|
throw new Error(`Restic backup output validation failed: ${result}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +472,79 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||||||
return { snapshot, nodes };
|
return { snapshot, nodes };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unlock = async (config: RepositoryConfig) => {
|
||||||
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
|
const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic unlock failed: ${res.stderr}`);
|
||||||
|
throw new Error(`Restic unlock failed: ${res.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
|
||||||
|
return { success: true, message: "Repository unlocked successfully" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => {
|
||||||
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
|
const args: string[] = ["--repo", repoUrl, "check"];
|
||||||
|
|
||||||
|
if (options?.readData) {
|
||||||
|
args.push("--read-data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await $`restic ${args}`.env(env).nothrow();
|
||||||
|
|
||||||
|
const stdout = res.text();
|
||||||
|
const stderr = res.stderr.toString();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic check failed: ${stderr}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
hasErrors: true,
|
||||||
|
output: stdout,
|
||||||
|
error: stderr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasErrors = stdout.includes("Fatal");
|
||||||
|
|
||||||
|
logger.info(`Restic check completed for repository: ${repoUrl}`);
|
||||||
|
return {
|
||||||
|
success: !hasErrors,
|
||||||
|
hasErrors,
|
||||||
|
output: stdout,
|
||||||
|
error: hasErrors ? "Repository contains errors" : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const repairIndex = async (config: RepositoryConfig) => {
|
||||||
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
const env = await buildEnv(config);
|
||||||
|
|
||||||
|
const res = await $`restic repair index --repo ${repoUrl}`.env(env).nothrow();
|
||||||
|
|
||||||
|
const stdout = res.text();
|
||||||
|
const stderr = res.stderr.toString();
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic repair index failed: ${stderr}`);
|
||||||
|
throw new Error(`Restic repair index failed: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Restic repair index completed for repository: ${repoUrl}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: stdout,
|
||||||
|
message: "Index repaired successfully",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const restic = {
|
export const restic = {
|
||||||
ensurePassfile,
|
ensurePassfile,
|
||||||
init,
|
init,
|
||||||
@@ -431,5 +552,8 @@ export const restic = {
|
|||||||
restore,
|
restore,
|
||||||
snapshots,
|
snapshots,
|
||||||
forget,
|
forget,
|
||||||
|
unlock,
|
||||||
ls,
|
ls,
|
||||||
|
check,
|
||||||
|
repairIndex,
|
||||||
};
|
};
|
||||||
|
|||||||
56
apps/server/src/utils/spawn.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onStdout?: (data: string) => void;
|
||||||
|
onStderr?: (error: string) => void;
|
||||||
|
onError?: (error: Error) => Promise<void> | void;
|
||||||
|
onClose?: (code: number | null) => Promise<void> | void;
|
||||||
|
finally?: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const safeSpawn = (params: Params) => {
|
||||||
|
const { command, args, env = {}, signal, ...callbacks } = params;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
signal: signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on("data", (data) => {
|
||||||
|
if (callbacks.onStdout) {
|
||||||
|
callbacks.onStdout(data.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (data) => {
|
||||||
|
if (callbacks.onStderr) {
|
||||||
|
callbacks.onStderr(data.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", async (error) => {
|
||||||
|
if (callbacks.onError) {
|
||||||
|
await callbacks.onError(error);
|
||||||
|
}
|
||||||
|
if (callbacks.finally) {
|
||||||
|
await callbacks.finally();
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", async (code) => {
|
||||||
|
if (callbacks.onClose) {
|
||||||
|
await callbacks.onClose(code);
|
||||||
|
}
|
||||||
|
if (callbacks.finally) {
|
||||||
|
await callbacks.finally();
|
||||||
|
}
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
BIN
assets/im-favicon.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/im.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
17
bun.lock
@@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
@@ -76,11 +77,13 @@
|
|||||||
"dockerode": "^4.0.8",
|
"dockerode": "^4.0.8",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"drizzle-orm": "^0.44.6",
|
"drizzle-orm": "^0.44.6",
|
||||||
|
"es-toolkit": "^1.41.0",
|
||||||
"hono": "^4.9.2",
|
"hono": "^4.9.2",
|
||||||
"hono-openapi": "^1.1.0",
|
"hono-openapi": "^1.1.0",
|
||||||
"http-errors-enhanced": "^3.0.2",
|
"http-errors-enhanced": "^3.0.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"tiny-typed-emitter": "^2.1.0",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -375,6 +378,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||||
|
|
||||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||||
@@ -811,7 +816,7 @@
|
|||||||
|
|
||||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
"es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
|
"es-toolkit": ["es-toolkit@1.41.0", "", {}, "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
||||||
|
|
||||||
@@ -1351,6 +1356,8 @@
|
|||||||
|
|
||||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
|
"tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
@@ -1481,6 +1488,10 @@
|
|||||||
|
|
||||||
"@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
"@npmcli/git/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
|
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
|
||||||
|
|
||||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||||
@@ -1591,6 +1602,8 @@
|
|||||||
|
|
||||||
"protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
|
"protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="],
|
||||||
|
|
||||||
|
"recharts/es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
|
||||||
|
|
||||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|
||||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
@@ -1657,6 +1670,8 @@
|
|||||||
|
|
||||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||||
|
|
||||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ services:
|
|||||||
target: development
|
target: development
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
devices:
|
||||||
|
- /dev/fuse:/dev/fuse
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
ports:
|
ports:
|
||||||
- "4096:4097"
|
- "4096:4097"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/lib/ironmount:/var/lib/ironmount
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
|
||||||
- /proc:/host/proc:ro
|
|
||||||
- ironmount_data:/data
|
|
||||||
|
|
||||||
- ./apps/client/app:/app/apps/client/app
|
- ./apps/client/app:/app/apps/client/app
|
||||||
- ./apps/server/src:/app/apps/server/src
|
- ./apps/server/src:/app/apps/server/src
|
||||||
@@ -27,17 +27,11 @@ services:
|
|||||||
target: production
|
target: production
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
devices:
|
||||||
|
- /dev/fuse:/dev/fuse
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
ports:
|
ports:
|
||||||
- "4096:4096"
|
- "4096:4096"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/lib/ironmount/:/var/lib/ironmount/
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
|
||||||
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rslave
|
|
||||||
- /var/lib/repositories/:/var/lib/repositories
|
|
||||||
- /proc:/host/proc:ro
|
|
||||||
- ironmount_data:/data
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
ironmount_data:
|
|
||||||
driver: local
|
|
||||||
|
|||||||