mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
8 Commits
v0.5.0
...
v0.6.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f620b4c45 | ||
|
|
3abf8ab12d | ||
|
|
b5ba03da3d | ||
|
|
b289920720 | ||
|
|
ef5d95d347 | ||
|
|
a1ef34118c | ||
|
|
59433f3686 | ||
|
|
3debd80e15 |
168
README.md
168
README.md
@@ -20,17 +20,14 @@
|
||||
|
||||
## Intro
|
||||
|
||||
Ironmount is a backup automation platform that helps you protect your data across multiple storage backends. Built on top of Restic, it provides an intuitive web interface to schedule, manage, and monitor encrypted backups of your remote storage. With support for Docker integration, Ironmount makes it easy to backup your container volumes automatically.
|
||||
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
|
||||
|
||||
- 💾 **Automated backups** with encryption, compression and retention policies powered by Restic
|
||||
- 📅 **Flexible scheduling** using cron expressions for automated backup jobs
|
||||
- 🔐 **End-to-end encryption** ensuring your data is always protected
|
||||
- 📦 **Snapshot management** with retention policies to optimize storage usage
|
||||
- 📊 **Monitoring and statistics** to track backup health and storage usage
|
||||
- ✅ **Multi-protocol support**: Backup from NFS, SMB, WebDAV, or local directories
|
||||
- 🔍 **Health checks** and automatic recovery to ensure backup reliability
|
||||
- **Automated backups** with encryption, compression and retention policies powered by Restic
|
||||
- **Flexible scheduling** For automated backup jobs with fine-grained retention policies
|
||||
- **End-to-end encryption** ensuring your data is always protected
|
||||
- **Multi-protocol support**: Backup from NFS, SMB, WebDAV, or local directories
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -48,7 +45,7 @@ services:
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
volumes:
|
||||
- /var/lib/ironmount/:/var/lib/ironmount/
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
```
|
||||
|
||||
Then, run the following command to start Ironmount:
|
||||
@@ -59,9 +56,158 @@ docker compose up -d
|
||||
|
||||
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
|
||||
|
||||
## Backups
|
||||
## 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
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
updateBackupSchedule,
|
||||
getBackupScheduleForVolume,
|
||||
runBackupNow,
|
||||
stopBackup,
|
||||
getSystemInfo,
|
||||
downloadResticPassword,
|
||||
} from "../sdk.gen";
|
||||
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
||||
import type {
|
||||
@@ -93,7 +95,11 @@ import type {
|
||||
GetBackupScheduleForVolumeData,
|
||||
RunBackupNowData,
|
||||
RunBackupNowResponse,
|
||||
StopBackupData,
|
||||
StopBackupResponse,
|
||||
GetSystemInfoData,
|
||||
DownloadResticPasswordData,
|
||||
DownloadResticPasswordResponse,
|
||||
} from "../types.gen";
|
||||
import { client as _heyApiClient } from "../client.gen";
|
||||
|
||||
@@ -1104,6 +1110,45 @@ export const runBackupNowMutation = (
|
||||
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);
|
||||
|
||||
/**
|
||||
@@ -1123,3 +1168,47 @@ export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -74,8 +74,13 @@ import type {
|
||||
GetBackupScheduleForVolumeResponses,
|
||||
RunBackupNowData,
|
||||
RunBackupNowResponses,
|
||||
StopBackupData,
|
||||
StopBackupResponses,
|
||||
StopBackupErrors,
|
||||
GetSystemInfoData,
|
||||
GetSystemInfoResponses,
|
||||
DownloadResticPasswordData,
|
||||
DownloadResticPasswordResponses,
|
||||
} from "./types.gen";
|
||||
import { client as _heyApiClient } from "./client.gen";
|
||||
|
||||
@@ -530,6 +535,16 @@ export const runBackupNow = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -541,3 +556,19 @@ export const getSystemInfo = <ThrowOnError extends boolean = false>(
|
||||
...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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export type RegisterResponses = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
@@ -44,6 +45,7 @@ export type LoginResponses = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
@@ -85,6 +87,7 @@ export type GetMeResponses = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
user?: {
|
||||
hasDownloadedResticPassword: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
@@ -149,6 +152,8 @@ export type ListVolumesResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -175,6 +180,7 @@ export type ListVolumesResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -196,6 +202,8 @@ export type CreateVolumeData = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -222,6 +230,7 @@ export type CreateVolumeData = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -241,6 +250,8 @@ export type CreateVolumeResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -267,6 +278,7 @@ export type CreateVolumeResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -288,6 +300,8 @@ export type TestConnectionData = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -314,6 +328,7 @@ export type TestConnectionData = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -386,6 +401,8 @@ export type GetVolumeResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -412,6 +429,7 @@ export type GetVolumeResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -435,6 +453,8 @@ export type UpdateVolumeData = {
|
||||
config?:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -461,6 +481,7 @@ export type UpdateVolumeData = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -488,6 +509,8 @@ export type UpdateVolumeResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -514,6 +537,7 @@ export type UpdateVolumeResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -962,12 +986,11 @@ export type DoctorRepositoryResponses = {
|
||||
* Doctor operation completed
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
steps: Array<{
|
||||
error: string | null;
|
||||
output: string | null;
|
||||
step: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
output?: string;
|
||||
}>;
|
||||
success: boolean;
|
||||
};
|
||||
@@ -1036,6 +1059,8 @@ export type ListBackupSchedulesResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -1062,6 +1087,7 @@ export type ListBackupSchedulesResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -1219,6 +1245,8 @@ export type GetBackupScheduleResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -1245,6 +1273,7 @@ export type GetBackupScheduleResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -1383,6 +1412,8 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
config:
|
||||
| {
|
||||
backend: "directory";
|
||||
path: string;
|
||||
readOnly?: false;
|
||||
}
|
||||
| {
|
||||
backend: "nfs";
|
||||
@@ -1409,6 +1440,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||
server: string;
|
||||
port?: number;
|
||||
password?: string;
|
||||
readOnly?: boolean;
|
||||
ssl?: boolean;
|
||||
username?: string;
|
||||
};
|
||||
@@ -1448,6 +1480,33 @@ export type 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;
|
||||
@@ -1468,6 +1527,24 @@ export type GetSystemInfoResponses = {
|
||||
|
||||
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 = {
|
||||
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
||||
};
|
||||
|
||||
@@ -70,6 +70,8 @@ body {
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { LifeBuoy } from "lucide-react";
|
||||
import { Outlet, useNavigate } from "react-router";
|
||||
import { Outlet, redirect, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import { appContext } from "~/context";
|
||||
@@ -16,6 +16,11 @@ export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||
const ctx = context.get(appContext);
|
||||
|
||||
if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
|
||||
throw redirect("/download-recovery-key");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const alertVariants = cva(
|
||||
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: {
|
||||
|
||||
106
apps/client/app/modules/auth/routes/download-recovery-key.tsx
Normal file
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 { Input } from "~/components/ui/input";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/login";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Login" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Sign in to your Ironmount account.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const loginSchema = type({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=1",
|
||||
@@ -33,8 +44,12 @@ export default function LoginPage() {
|
||||
|
||||
const login = useMutation({
|
||||
...loginMutation(),
|
||||
onSuccess: async () => {
|
||||
navigate("/volumes");
|
||||
onSuccess: async (data) => {
|
||||
if (data.user && !data.user.hasDownloadedResticPassword) {
|
||||
navigate("/download-recovery-key");
|
||||
} else {
|
||||
navigate("/volumes");
|
||||
}
|
||||
},
|
||||
onError: (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 { Input } from "~/components/ui/input";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/onboarding";
|
||||
|
||||
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({
|
||||
username: "2<=string<=50",
|
||||
password: "string>=8",
|
||||
@@ -37,7 +48,7 @@ export default function OnboardingPage() {
|
||||
...registerMutation(),
|
||||
onSuccess: async () => {
|
||||
toast.success("Admin user created successfully!");
|
||||
navigate("/volumes");
|
||||
navigate("/download-recovery-key");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
|
||||
@@ -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 { OnOff } from "~/components/onoff";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -18,12 +18,14 @@ type Props = {
|
||||
schedule: BackupSchedule;
|
||||
handleToggleEnabled: (enabled: boolean) => void;
|
||||
handleRunBackupNow: () => void;
|
||||
handleStopBackup: () => void;
|
||||
handleDeleteSchedule: () => void;
|
||||
setIsEditMode: (isEdit: boolean) => void;
|
||||
};
|
||||
|
||||
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 summary = useMemo(() => {
|
||||
@@ -75,16 +77,17 @@ export const ScheduleSummary = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleRunBackupNow}
|
||||
disabled={schedule.lastBackupStatus === "in_progress"}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Backup now</span>
|
||||
</Button>
|
||||
{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">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Backup now</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
<span className="sm:inline">Edit schedule</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
deleteBackupScheduleMutation,
|
||||
listSnapshotsOptions,
|
||||
updateBackupScheduleMutation,
|
||||
stopBackupMutation,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/lib/errors";
|
||||
import { getCronExpression } from "~/utils/utils";
|
||||
@@ -19,6 +20,16 @@ import type { Route } from "./+types/backup-details";
|
||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||
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) => {
|
||||
const { data } = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||
|
||||
@@ -34,9 +45,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
|
||||
|
||||
const { data: schedule } = useQuery({
|
||||
...getBackupScheduleOptions({
|
||||
path: { scheduleId: params.id },
|
||||
}),
|
||||
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
|
||||
initialData: loaderData,
|
||||
refetchInterval: 10000,
|
||||
refetchOnWindowFocus: true,
|
||||
@@ -47,13 +56,10 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
isLoading,
|
||||
failureReason,
|
||||
} = useQuery({
|
||||
...listSnapshotsOptions({
|
||||
path: { name: schedule.repository.name },
|
||||
query: { backupId: schedule.id.toString() },
|
||||
}),
|
||||
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
|
||||
});
|
||||
|
||||
const upsertSchedule = useMutation({
|
||||
const updateSchedule = useMutation({
|
||||
...updateBackupScheduleMutation(),
|
||||
onSuccess: () => {
|
||||
toast.success("Backup schedule saved successfully");
|
||||
@@ -72,9 +78,17 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
toast.success("Backup started successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to start backup", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
toast.error("Failed to start backup", { 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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,9 +99,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
navigate("/backups");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete backup schedule", {
|
||||
description: parseError(error)?.message,
|
||||
});
|
||||
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,7 +116,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
|
||||
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
||||
|
||||
upsertSchedule.mutate({
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
repositoryId: formValues.repositoryId,
|
||||
@@ -118,9 +130,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (enabled: boolean) => {
|
||||
if (!schedule) return;
|
||||
|
||||
upsertSchedule.mutate({
|
||||
updateSchedule.mutate({
|
||||
path: { scheduleId: schedule.id.toString() },
|
||||
body: {
|
||||
repositoryId: schedule.repositoryId,
|
||||
@@ -133,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) {
|
||||
return (
|
||||
<div>
|
||||
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
|
||||
<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
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsEditMode(false)}>
|
||||
@@ -171,8 +165,9 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||
<div className="flex flex-col gap-6">
|
||||
<ScheduleSummary
|
||||
handleToggleEnabled={handleToggleEnabled}
|
||||
handleRunBackupNow={handleRunBackupNow}
|
||||
handleDeleteSchedule={handleDeleteSchedule}
|
||||
handleRunBackupNow={() => runBackupNow.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
handleStopBackup={() => stopBackup.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
handleDeleteSchedule={() => deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } })}
|
||||
setIsEditMode={setIsEditMode}
|
||||
schedule={schedule}
|
||||
/>
|
||||
|
||||
@@ -11,10 +11,10 @@ import type { Route } from "./+types/backups";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Ironmount" },
|
||||
{ title: "Backup Jobs" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
content: "Automate volume backups with scheduled jobs and retention policies.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ import { listRepositories, listVolumes } from "~/api-client";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Ironmount" },
|
||||
{ title: "Create Backup Job" },
|
||||
{
|
||||
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) {
|
||||
return [
|
||||
{ title: "Ironmount - Repositories" },
|
||||
{ title: "Repositories" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your backup repositories",
|
||||
content: "Manage your backup repositories with encryption and compression.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ import { RepositorySnapshotsTabContent } from "../tabs/snapshots";
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Ironmount - ${params.name}` },
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your restic backup repositories with ease.",
|
||||
content: "View repository configuration, status, and snapshots.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,16 @@ import { SnapshotFileBrowser } from "~/modules/backups/components/snapshot-file-
|
||||
import { getSnapshotDetails } from "~/api-client";
|
||||
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) => {
|
||||
const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } });
|
||||
if (snapshot.data) return snapshot.data;
|
||||
|
||||
@@ -135,9 +135,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Doctor Results</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{doctorMutation.data?.message || "Repository doctor operation completed"}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogDescription>Repository doctor operation completed</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{doctorMutation.data && (
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { KeyRound, User } from "lucide-react";
|
||||
import { Download, KeyRound, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { changePasswordMutation, logoutMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||
import {
|
||||
changePasswordMutation,
|
||||
downloadResticPasswordMutation,
|
||||
logoutMutation,
|
||||
} from "~/api-client/@tanstack/react-query.gen";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
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";
|
||||
@@ -13,7 +26,7 @@ import type { Route } from "./+types/settings";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Settings - Ironmount" },
|
||||
{ title: "Settings" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your account settings and preferences.",
|
||||
@@ -30,6 +43,8 @@ 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({
|
||||
@@ -56,6 +71,28 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -77,6 +114,21 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -143,6 +195,69 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ import { DockerTabContent } from "../tabs/docker";
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Ironmount - ${params.name}` },
|
||||
{ title: params.name },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
content: "View and manage volume details, configuration, and files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { Route } from "./+types/volumes";
|
||||
|
||||
export function meta(_: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Ironmount" },
|
||||
{ title: "Volumes" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Create, manage, monitor, and automate your Docker volumes with ease.",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { layout, type RouteConfig, route } from "@react-router/dev/routes";
|
||||
export default [
|
||||
route("onboarding", "./modules/auth/routes/onboarding.tsx"),
|
||||
route("login", "./modules/auth/routes/login.tsx"),
|
||||
route("download-recovery-key", "./modules/auth/routes/download-recovery-key.tsx"),
|
||||
layout("./components/layout.tsx", [
|
||||
route("/", "./routes/root.tsx"),
|
||||
route("volumes", "./modules/volumes/routes/volumes.tsx"),
|
||||
|
||||
1
apps/server/drizzle/0010_perfect_proemial_gods.sql
Normal file
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
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,
|
||||
"tag": "0009_little_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1762610065889,
|
||||
"tag": "0010_perfect_proemial_gods",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,7 @@ interface ServerEvents {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
status: "success" | "error";
|
||||
status: "success" | "error" | "stopped";
|
||||
}) => void;
|
||||
"volume:mounted": (data: { volumeName: string }) => void;
|
||||
"volume:unmounted": (data: { volumeName: string }) => void;
|
||||
|
||||
@@ -32,6 +32,7 @@ export const usersTable = sqliteTable("users_table", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
username: text().notNull().unique(),
|
||||
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())`),
|
||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
@@ -43,7 +43,15 @@ export const authController = new Hono()
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -64,7 +72,11 @@ export const authController = new Hono()
|
||||
return c.json<LoginDto>({
|
||||
success: true,
|
||||
message: "Login successful",
|
||||
user: { id: user.id, username: user.username },
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
||||
|
||||
@@ -18,6 +18,7 @@ const loginResponseSchema = type({
|
||||
user: type({
|
||||
id: "number",
|
||||
username: "string",
|
||||
hasDownloadedResticPassword: "boolean",
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ declare module "hono" {
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,15 @@ export class AuthService {
|
||||
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 {
|
||||
sessionId,
|
||||
user: { id: user.id, username: user.username },
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -109,6 +121,7 @@ export class AuthService {
|
||||
user: {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
hasDownloadedResticPassword: session.user.hasDownloadedResticPassword,
|
||||
},
|
||||
session: {
|
||||
id: session.session.id,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getBackupScheduleForVolumeDto,
|
||||
listBackupSchedulesDto,
|
||||
runBackupNowDto,
|
||||
stopBackupDto,
|
||||
updateBackupScheduleDto,
|
||||
updateBackupScheduleBody,
|
||||
type CreateBackupScheduleDto,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
type GetBackupScheduleForVolumeResponseDto,
|
||||
type ListBackupSchedulesResponseDto,
|
||||
type RunBackupNowDto,
|
||||
type StopBackupDto,
|
||||
type UpdateBackupScheduleDto,
|
||||
} from "./backups.dto";
|
||||
import { backupsService } from "./backups.service";
|
||||
@@ -69,4 +71,11 @@ export const backupScheduleController = new Hono()
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 cron from "node-cron";
|
||||
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 { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||
import { restic } from "../../utils/restic";
|
||||
@@ -11,6 +11,8 @@ import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backu
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { serverEvents } from "../../core/events";
|
||||
|
||||
const runningBackups = new Map<number, AbortController>();
|
||||
|
||||
const calculateNextRun = (cronExpression: string): number => {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
@@ -160,6 +162,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
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({
|
||||
where: eq(volumesTable.id, schedule.volumeId),
|
||||
});
|
||||
@@ -193,6 +200,9 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now() })
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
|
||||
const abortController = new AbortController();
|
||||
runningBackups.set(scheduleId, abortController);
|
||||
|
||||
try {
|
||||
const volumePath = getVolumePath(volume);
|
||||
|
||||
@@ -200,8 +210,10 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
exclude?: string[];
|
||||
include?: string[];
|
||||
tags?: string[];
|
||||
signal?: AbortSignal;
|
||||
} = {
|
||||
tags: [schedule.id.toString()],
|
||||
signal: abortController.signal,
|
||||
};
|
||||
|
||||
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
|
||||
@@ -259,6 +271,8 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
runningBackups.delete(scheduleId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,6 +302,34 @@ const getScheduleForVolume = async (volumeId: number) => {
|
||||
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 = {
|
||||
listSchedules,
|
||||
getSchedule,
|
||||
@@ -297,4 +339,5 @@ export const backupsService = {
|
||||
executeBackup,
|
||||
getSchedulesToExecute,
|
||||
getScheduleForVolume,
|
||||
stopBackup,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const eventsController = new Hono().get("/", (c) => {
|
||||
scheduleId: number;
|
||||
volumeName: string;
|
||||
repositoryName: string;
|
||||
status: "success" | "error";
|
||||
status: "success" | "error" | "stopped";
|
||||
}) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify(data),
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
import { Hono } from "hono";
|
||||
import { systemInfoDto, type SystemInfoDto } from "./system.dto";
|
||||
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();
|
||||
export const systemController = new Hono()
|
||||
.get("/info", systemInfoDto, async (c) => {
|
||||
const info = await systemService.getSystemInfo();
|
||||
|
||||
return c.json<SystemInfoDto>(info, 200);
|
||||
});
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,3 +26,23 @@ export const systemInfoDto = describeRoute({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,10 +7,12 @@ import { eq } from "drizzle-orm";
|
||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||
import slugify from "slugify";
|
||||
import { getCapabilities } from "../../core/capabilities";
|
||||
import { OPERATION_TIMEOUT } from "../../core/constants";
|
||||
import { db } from "../../db/db";
|
||||
import { volumesTable } from "../../db/schema";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
||||
import { withTimeout } from "../../utils/timeout";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
import type { UpdateVolumeBody } from "./volume.dto";
|
||||
import { getVolumePath } from "./helpers";
|
||||
@@ -128,7 +130,10 @@ const getVolume = async (name: string) => {
|
||||
|
||||
let statfs: Partial<StatFs> = {};
|
||||
if (volume.status === "mounted") {
|
||||
statfs = await getStatFs(getVolumePath(volume)).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 };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { RepositoryConfig } from "@ironmount/schemas/restic";
|
||||
import { type } from "arktype";
|
||||
import { $ } from "bun";
|
||||
@@ -114,7 +115,7 @@ const init = async (config: RepositoryConfig) => {
|
||||
const backup = async (
|
||||
config: RepositoryConfig,
|
||||
source: string,
|
||||
options?: { exclude?: string[]; include?: string[]; tags?: string[] },
|
||||
options?: { exclude?: string[]; include?: string[]; tags?: string[]; signal?: AbortSignal },
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
@@ -148,32 +149,68 @@ const backup = async (
|
||||
|
||||
args.push("--json");
|
||||
|
||||
// await $`restic unlock --repo ${repoUrl}`.env(env).nothrow();
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("restic", args, {
|
||||
env: { ...process.env, ...env },
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (includeFile) {
|
||||
await fs.unlink(includeFile).catch(() => {});
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||
throw new Error(`Restic backup failed: ${res.stderr}`);
|
||||
}
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
// res is a succession of JSON objects, we need to parse the last one which contains the summary
|
||||
const stdout = res.text();
|
||||
const outputLines = stdout.trim().split("\n");
|
||||
const lastLine = outputLines[outputLines.length - 1];
|
||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const result = backupOutputSchema(resSummary);
|
||||
child.on("error", async (error) => {
|
||||
if (includeFile) {
|
||||
await fs.unlink(includeFile).catch(() => {});
|
||||
}
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
logger.error(`Restic backup output validation failed: ${result}`);
|
||||
throw new Error(`Restic backup output validation failed: ${result}`);
|
||||
}
|
||||
if (error.name === "AbortError") {
|
||||
logger.info("Restic backup process was aborted");
|
||||
reject(error);
|
||||
} else {
|
||||
logger.error(`Restic backup process error: ${error.message}`);
|
||||
reject(new Error(`Restic backup process error: ${error.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
child.on("close", async (code) => {
|
||||
if (includeFile) {
|
||||
await fs.unlink(includeFile).catch(() => {});
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(`Restic backup failed with exit code ${code}: ${stderr}`);
|
||||
reject(new Error(`Restic backup failed: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const outputLines = stdout.trim().split("\n");
|
||||
const lastLine = outputLines[outputLines.length - 1];
|
||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||
|
||||
const result = backupOutputSchema(resSummary);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
logger.error(`Restic backup output validation failed: ${result}`);
|
||||
reject(new Error(`Restic backup output validation failed: ${result}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to parse restic backup output: ${error}`);
|
||||
reject(new Error(`Failed to parse restic backup output: ${error}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const restoreOutputSchema = type({
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
ports:
|
||||
- "4096:4097"
|
||||
volumes:
|
||||
- /var/lib/ironmount/:/var/lib/ironmount/
|
||||
- /var/lib/ironmount:/var/lib/ironmount
|
||||
|
||||
- ./apps/client/app:/app/apps/client/app
|
||||
- ./apps/server/src:/app/apps/server/src
|
||||
|
||||
BIN
screenshots/add-volume.png
Normal file
BIN
screenshots/add-volume.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
BIN
screenshots/backups-list.png
Normal file
BIN
screenshots/backups-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
screenshots/restoring.png
Normal file
BIN
screenshots/restoring.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Reference in New Issue
Block a user